Day 15: Standard Library Interfaces
Core Concept: Small Interfaces Compose Well
Go’s philosophy: “the bigger the interface, the weaker the abstraction.” Most standard library interfaces have one or two methods. Implement them and your type works everywhere.
io.Reader and io.Writer
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Read fills p and returns how many bytes were written. It returns io.EOF when done — not an error, a signal.
// Anything that reads can be passed to io.Copy
io.Copy(os.Stdout, strings.NewReader("hello"))
// Compose readers
r := io.MultiReader(strings.NewReader("first"), strings.NewReader("second"))
// Wrap a writer for buffering
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
fmt.Stringer
type Stringer interface {
String() string
}
Implement String() and fmt.Println, fmt.Sprintf("%v"), etc. will call it automatically.
sort.Interface
type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}
Implement it and call sort.Sort(yourSlice). Use sort.Reverse for descending order without re-implementing.
encoding.TextMarshaler / TextUnmarshaler
type TextMarshaler interface {
MarshalText() ([]byte, error)
}
type TextUnmarshaler interface {
UnmarshalText(text []byte) error
}
Types that implement these are automatically handled by encoding/json, encoding/xml, and fmt.
Struct Embedding to Satisfy Interfaces
type LoggingWriter struct {
io.Writer // embed — promotes Write method
prefix string
}
func (lw *LoggingWriter) Write(p []byte) (int, error) {
fmt.Printf("[%s] %s", lw.prefix, p)
return lw.Writer.Write(p) // delegate to embedded writer
}
Labs
Lab 1: io.Reader — ROT13Reader
What you’ll practise: Implementing io.Reader by wrapping another Reader.
Task:
Implement a ROT13Reader struct that wraps an io.Reader and transforms each byte by applying the ROT13 cipher (rotate alphabetic characters by 13). Test it using strings.NewReader as the source.
Steps:
- Define
type ROT13Reader struct { r io.Reader } - Implement
Read(p []byte) (int, error)— callr.Read, then rotate each byte in-place - Test by reading
"Hello, World!"through the ROT13Reader and printing the result - Run it a second time through another ROT13Reader — confirm you get the original string back
type ROT13Reader struct{ r io.Reader }
func (rr ROT13Reader) Read(p []byte) (int, error) {
n, err := rr.r.Read(p)
for i := 0; i < n; i++ {
b := p[i]
switch {
case b >= 'A' && b <= 'Z':
p[i] = 'A' + (b-'A'+13)%26
case b >= 'a' && b <= 'z':
p[i] = 'a' + (b-'a'+13)%26
}
}
return n, err
}
Expected output:
Uryyb, Jbeyq!
Hello, World!
Checkpoint: io.ReadAll on your ROT13Reader returns the correctly rotated bytes.
Lab 2: io.Writer — CountingWriter
What you’ll practise: Implementing io.Writer as a transparent wrapper that counts bytes.
Task:
Implement a CountingWriter that wraps any io.Writer and keeps a running total of bytes written. Use fmt.Fprintf to write formatted output through it and verify the byte count.
Steps:
- Define
type CountingWriter struct { w io.Writer; n int64 } - Implement
Write(p []byte) (int, error)— delegate tow, addlen(p)ton - Write several strings through it using
fmt.Fprintf - Assert the final count matches the sum of all written string lengths
type CountingWriter struct {
w io.Writer
n int64
}
func (cw *CountingWriter) Write(p []byte) (int, error) {
n, err := cw.w.Write(p)
cw.n += int64(n)
return n, err
}
func (cw *CountingWriter) BytesWritten() int64 { return cw.n }
Expected output:
Hello, Go!
Bytes written: 10
Checkpoint: cw.BytesWritten() equals len("Hello, Go!\n") after the fmt.Fprintf call.
Lab 3: io.ReadWriter — Custom TeeWriter
What you’ll practise: Writing to two destinations simultaneously by composing io.Writer.
Task:
Implement a TeeWriter that writes to two io.Writer destinations at once (similar to io.MultiWriter but built from scratch). If the first write succeeds but the second fails, return the error from the second.
Steps:
- Define
type TeeWriter struct { a, b io.Writer } - Implement
Write— write toa, then tob, return the first error encountered - Use it to write to both
os.Stdoutand abytes.Buffersimultaneously - Verify that the buffer contains the same bytes that appeared on stdout
type TeeWriter struct{ a, b io.Writer }
func (tw *TeeWriter) Write(p []byte) (int, error) {
n, err := tw.a.Write(p)
if err != nil {
return n, err
}
_, err = tw.b.Write(p)
return n, err
}
Expected output:
stdout: Hello from TeeWriter
buffer: Hello from TeeWriter
Checkpoint: buf.String() equals the string sent to the TeeWriter.
Lab 4: fmt.Stringer and sort.Interface — Temperature
What you’ll practise: Implementing two stdlib interfaces on a custom type to make it printable and sortable.
Task:
Define a Temperature type (wrapping float64). Implement String() string so fmt.Println formats it as "23.5°C". Then implement sort.Interface on []Temperature and sort a slice in ascending order.
Steps:
- Define
type Temperature float64 - Implement
String()usingfmt.Sprintf("%.1f°C", float64(t)) - Define
type ByTemp []Temperatureand implementLen,Less,Swap - Sort a slice and print it before and after sorting
type Temperature float64
func (t Temperature) String() string {
return fmt.Sprintf("%.1f°C", float64(t))
}
type ByTemp []Temperature
func (b ByTemp) Len() int { return len(b) }
func (b ByTemp) Less(i, j int) bool { return b[i] < b[j] }
func (b ByTemp) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
Expected output:
Before: [36.6°C 0.0°C 100.0°C -40.0°C]
After: [-40.0°C 0.0°C 36.6°C 100.0°C]
Checkpoint: fmt.Println(temps[0]) prints with the °C suffix, and sort.Sort(ByTemp(temps)) orders them numerically.
Lab 5: encoding.TextMarshaler/TextUnmarshaler — Config
What you’ll practise: Implementing the text codec interfaces so a struct round-trips through a key=value text format.
Task:
Define a Config struct with Host string, Port int, and Debug bool fields. Implement MarshalText to produce a key=value\n format and UnmarshalText to parse it back. Verify the round-trip.
Steps:
- Implement
MarshalText() ([]byte, error)usingfmt.Sprintffor each field - Implement
UnmarshalText(text []byte) errorusingbufio.Scannerandstrings.Cut - Marshal a Config, print the text, then unmarshal it into a second Config
- Assert both Configs are equal
func (c Config) MarshalText() ([]byte, error) {
return []byte(fmt.Sprintf("host=%s\nport=%d\ndebug=%v\n",
c.Host, c.Port, c.Debug)), nil
}
func (c *Config) UnmarshalText(text []byte) error {
scanner := bufio.NewScanner(bytes.NewReader(text))
for scanner.Scan() {
key, val, _ := strings.Cut(scanner.Text(), "=")
// set fields by key...
}
return scanner.Err()
}
Expected output:
Marshalled:
host=localhost
port=8080
debug=true
Round-trip equal: true
Checkpoint: The unmarshalled Config has the same field values as the original.
Lab 6: io.Closer — Logging Close Wrapper
What you’ll practise: Implementing io.Closer and using defer for guaranteed resource release.
Task:
Create a LoggingFile struct that wraps *os.File. Override Close() to log a message before delegating. Open a real file, write to it, and use defer lf.Close() to close it — confirm the log message appears even when an error occurs.
Steps:
- Define
type LoggingFile struct { f *os.File; name string } - Forward
Writetof.Write - Implement
Close() error— log"closing <name>", then callf.Close() - Open a temp file, write a line, defer Close, return from the function — observe the log
type LoggingFile struct {
f *os.File
name string
}
func (lf *LoggingFile) Write(p []byte) (int, error) { return lf.f.Write(p) }
func (lf *LoggingFile) Close() error {
fmt.Printf("closing %s\n", lf.name)
return lf.f.Close()
}
Expected output:
wrote 12 bytes
closing /tmp/demo123.txt
Checkpoint: The “closing” log line appears before the program exits, even if you add an early return.
Lab 7: Compose Stdlib Interfaces — LimitedLogger
What you’ll practise: Composing multiple stdlib interfaces into a single type.
Task:
Build a LimitedLogger that wraps an io.Writer and implements io.Writer itself. It enforces a maximum byte budget: once the budget is exhausted it returns 0, ErrBudgetExhausted instead of writing. Use it as the output for a log.Logger.
Steps:
- Define
type LimitedLogger struct { w io.Writer; budget int64; written int64 } - Implement
Write(p []byte) (int, error)— check remaining budget, truncate or reject - Pass it to
log.New(ll, "PREFIX: ", 0)and write enough log entries to hit the limit - Confirm that writes beyond the budget return the custom error
var ErrBudgetExhausted = errors.New("log budget exhausted")
func (ll *LimitedLogger) Write(p []byte) (int, error) {
remaining := ll.budget - ll.written
if remaining <= 0 {
return 0, ErrBudgetExhausted
}
if int64(len(p)) > remaining {
p = p[:remaining]
}
n, err := ll.w.Write(p)
ll.written += int64(n)
return n, err
}
Expected output:
PREFIX: message 1
PREFIX: message 2
PREFIX: messag <- truncated at budget
write error: log budget exhausted
Checkpoint: ll.written equals the budget limit after the budget is exceeded, and further writes return ErrBudgetExhausted.
Final Lab (Project): Interface Showcase
What you’ll practise: Combining all stdlib interfaces — io.Reader, io.Writer, fmt.Stringer, sort.Interface, encoding.TextMarshaler/TextUnmarshaler — into a coherent program.
Task: Build a program that demonstrates all four interface implementations working together.
Steps:
- Implement
CountingReaderwrappingio.Reader— counts bytes read - Implement
TeeWriterwriting to twoio.Writers simultaneously - Implement
Temperaturewithfmt.Stringerandsort.Interfaceon[]Temperature - Implement
Configwithencoding.TextMarshalerandTextUnmarshaler - Compose: read from a file through
CountingReader, write to both stdout and a buffer viaTeeWriter, sort a temperature slice, and round-trip a Config
// Wire them together in main:
cr := &CountingReader{r: file}
tw := &TeeWriter{a: os.Stdout, b: &buf}
io.Copy(tw, cr)
fmt.Printf("read %d bytes\n", cr.N)
sort.Sort(ByTemp(temps))
fmt.Println(temps)
text, _ := cfg.MarshalText()
var cfg2 Config
cfg2.UnmarshalText(text)
Expected output:
[file contents appear on stdout and in buffer]
read 42 bytes
[-40.0°C 0.0°C 36.6°C 100.0°C]
round-trip ok: true
Checkpoint: All four components run without error and the CountingReader count matches the actual file size.
Day Project: Interface Showcase
Implement:
- A
CountingReaderthat wrapsio.Readerand counts bytes read - A
TeeWriterthat writes to twoio.Writers simultaneously - A
Temperaturetype withfmt.Stringer,sort.Interfaceon[]Temperature - A
Configstruct that implementsencoding.TextMarshalerandTextUnmarshaler
Compose them: read from a file, count bytes, write to both stdout and a buffer.
Extension ideas: implement io.WriterTo and io.ReaderFrom on your types for direct copy optimisation.
Official Documentation
io— Reader, Writer, EOF, Copy, MultiReader, WriterTo, ReaderFrombufio— buffered I/O wrappers (NewWriter, Flush)fmt— Stringer interface, Printf, Sprintfsort— sort.Interface, Sort, Reversestrings— NewReader for in-memory string readingos— Stdout, file I/O- Language Spec: Interface types — composing interfaces
- Effective Go: Interfaces and other types — interface design
- Go Blog: Laws of Reflection — how interfaces work under the hood