Day 5 — Interfaces, Type Assertions & Errors
Day 5 — Interfaces, Type Assertions & Errors
Day 11 — Interfaces
Stage II · ~3h (theory-heavy)
Goal: Define and satisfy small interfaces implicitly, prefer composition over giant interface bags, understand any, and refactor a domain dependency behind an interface without a mock framework.
In Go, interfaces are satisfied implicitly: if type T has the methods, it implements I—no implements keyword. That enables decoupling without ceremony.
Why this day exists
Interfaces are how Go expresses:
- Polymorphism (
io.Reader,fmt.Stringer)
- Test seams (swap a real store for a fake—Day 17)
- Package boundaries that depend on behavior, not concrete structs
Misuse patterns are common: huge interfaces, interfaces defined on the producer side only, premature abstraction. Day 11 builds judgment alongside syntax.
Theory 1 — Interface types
type Stringer interface {
String() string
}
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}An interface type describes a method set. A value of interface type holds:
- A dynamic type
- A dynamic value (or pointer) of that type
Empty interface / any
var x any // alias of interface{}
x = 1
x = "hi"
x = struct{}{}any means “any type”—and therefore no methods you can call without assertion (Day 12). Prefer specific interfaces.
Theory 2 — Implicit satisfaction
type Greeter interface {
Greet() string
}
type Person struct{ Name string }
func (p Person) Greet() string {
return "hello, " + p.Name
}
var g Greeter = Person{Name: "Ada"} // OK — no declaration on PersonCompile-time check without assigning:
var _ Greeter = Person{} // value methods OK
var _ Greeter = (*Person)(nil) // if methods are on *Person, use pointer formMethod sets again
| Receiver | Methods available on | Methods available on *T |
|---|---|---|
(T) value |
T and *T |
yes |
(*T) pointer |
only *T |
yes |
func (p *Person) Greet() string { return p.Name }
// var g Greeter = Person{} // FAIL
var g Greeter = &Person{} // OKTheory 3 — Small interfaces
Stdlib wisdom: accept interfaces, return structs (guideline, not law).
// Good: function depends on minimal behavior
func CountLines(r io.Reader) (int, error) {
sc := bufio.NewScanner(r)
n := 0
for sc.Scan() {
n++
}
return n, sc.Err()
}Callers may pass *os.File, *bytes.Buffer, strings.NewReader, network conns, etc.
Define interfaces where they are consumed
// in package report
type Store interface {
All() ([]Item, error)
}
func Summary(s Store) (string, error) { /* ... */ }The concrete database type can live in another package without importing report.
Anti-pattern: god interfaces
// Too big — hard to fake, hard to implement
type Service interface {
Create(...)
Update(...)
Delete(...)
List(...)
Search(...)
ExportCSV(...)
SendEmail(...)
// ...
}Split by consumer need (ItemReader, ItemWriter, …) or keep concrete until a second implementation exists.
Theory 4 — Interface values and nil (preview)
var r io.Reader
fmt.Println(r == nil) // true
r = (*bytes.Buffer)(nil)
fmt.Println(r == nil) // false! — typed nil inside non-nil interfaceThis is one of Go’s classic gotchas. Day 12 treats it fully. Today: know that “nil concrete pointer in an interface” is not an untyped nil interface.
Theory 5 — Composition of interfaces
type Reader interface {
Read(p []byte) (n int, err error)
}
type Closer interface {
Close() error
}
type ReadCloser interface {
Reader
Closer
}io.ReadCloser is exactly this pattern. Embedding interfaces unions method sets.
Theory 6 — Type assertions and switches (light)
var s Stringer = Person{Name: "x"}
p := s.(Person) // panics if wrong
p, ok := s.(Person)
if !ok { /* ... */ }Prefer comma-ok. Full type-switch mastery is Day 12.
Theory 7 — When not to use an interface
| Situation | Prefer |
|---|---|
| One implementation forever | Concrete type |
| You control both sides and no tests need a seam yet | Concrete |
| Interface has many methods already | Split or keep concrete |
| You only need a function | Function value / http.HandlerFunc style |
Rule of thumb: introduce an interface when you need a second implementation (including a test fake) or when the stdlib already defines the seam (io.Reader).
Worked examples bank
Example A — Storage behind interface
package app
type User struct {
ID string
Name string
}
type UserStore interface {
Find(id string) (User, error)
Save(User) error
}
type Service struct {
users UserStore
}
func NewService(users UserStore) *Service {
return &Service{users: users}
}
func (s *Service) Rename(id, name string) error {
u, err := s.users.Find(id)
if err != nil {
return err
}
u.Name = name
return s.users.Save(u)
}Example B — Memory implementation
type MemUsers struct {
m map[string]User
}
func NewMemUsers() *MemUsers {
return &MemUsers{m: make(map[string]User)}
}
func (m *MemUsers) Find(id string) (User, error) {
u, ok := m.m[id]
if !ok {
return User{}, fmt.Errorf("not found: %s", id)
}
return u, nil
}
func (m *MemUsers) Save(u User) error {
m.m[u.ID] = u
return nil
}
var _ UserStore = (*MemUsers)(nil)Example C — fmt.Stringer
func (u User) String() string {
return u.ID + ":" + u.Name
}
fmt.Println(User{ID: "1", Name: "Ada"}) // uses Stringer if applicableExample D — Accept small interface
type Namer interface {
Name() string
}
func Banner(n Namer) string {
return "*** " + n.Name() + " ***"
}Example E — Interface embedding
type ReadSeeker interface {
io.Reader
io.Seeker
}
func rewindAndRead(rs ReadSeeker) ([]byte, error) {
if _, err := rs.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(rs)
}Labs
Suggested workspace: ~/lab/90daysofx/go/day11
Lab 1 — Refactor behind an interface
mkdir -p ~/lab/90daysofx/go/day11
cd ~/lab/90daysofx/go/day11
go mod init example.com/day11Take a Day 10-style domain (todo, inventory, or users) and:
- Define a small store interface in the package that consumes it (or a
servicepackage).
- Provide an in-memory concrete implementation.
- Wire the concrete type in
mainonly.
- Keep business logic depending on the interface.
Lab 2 — Compile-time assertion
Add var _ Store = (*MemStore)(nil) (or your names). Deliberately break a method signature and observe the compile error—then fix.
Lab 3 — Stdlib interface use
Write a function that counts words from any io.Reader. Call it with:
strings.NewReader
bytes.NewBufferString
- an
os.File
Same function, three sources.
Lab 4 — Judgment write-up
In NOTES.md: when would you not introduce your store interface yet? Three sentences.
Common gotchas
| Gotcha | Fix |
|---|---|
| Giant interfaces | Split by consumer |
| Defining interfaces only next to concrete type “because Java” | Define on consumer side when possible |
| Value vs pointer method set mismatch | Match receivers; use var _ I = (*T)(nil) |
Using any to avoid design |
Prefer real methods |
| Interface nil gotcha | Day 12; avoid returning bare typed nils as errors/interfaces carelessly |
| Interface for single-use concrete | Delete until needed |
Checkpoint
- Implicit satisfaction explained
- Small interface designed for a consumer
- Concrete impl + compile-time assert
- Function accepts
io.Reader(or similar)
- Method set
Tvs*Tclear
- Notes on when not to abstract
Commit
git add .
git commit -m "day11: interfaces + store seam"Tomorrow
Day 12 — Type assertions, type switches & nil interfaces: safe extraction of dynamic types, exhaustive-style switches, and the typed-nil interface trap that breaks err != nil checks.
Day 12 — Type assertions & switches
Stage II · ~3h (theory-heavy)
Goal: Extract dynamic types safely with assertions and type switches, and fully understand the nil interface / typed-nil trap so if err != nil never lies to you again.
Type assertions are not conversions. T(x) converts; x.(T) asks “is the dynamic type of interface value x assignable to T?”
Why this day exists
Interfaces erase static type. Sometimes you need it back:
- Optional capabilities (
Flush(),Close())
- Discriminating error types (Day 13 uses
errors.As)
- Decoding
anyfrom JSON loosely
- Avoiding panics from failed assertions
The nil-interface bug is legendary: returning a nil concrete pointer as an error interface makes err != nil true. Day 12 inoculates you.
Theory 1 — Type assertion forms
Given var i I holding some dynamic value:
// One-value form — panics if wrong or i is nil interface
t := i.(Concrete)
// Two-value form — never panics
t, ok := i.(Concrete)
if !ok {
// not Concrete (or nil interface)
}Laws
imust be an interface value (or type parameter constrained appropriately).
- Assertion fails if dynamic type is not
Concrete/ not assignable to it.
- Prefer comma-ok unless you have just checked the type.
var i any = "hello"
s := i.(string) // OK
// n := i.(int) // panic
n, ok := i.(int) // ok=false, n=0Theory 2 — Type switches
func describe(v any) string {
switch x := v.(type) {
case nil:
return "nil"
case int:
return fmt.Sprintf("int %d", x)
case string:
return fmt.Sprintf("string %q", x)
case []byte:
return fmt.Sprintf("bytes len=%d", len(x))
default:
return fmt.Sprintf("other %T", x)
}
}In each case, x has the case’s type. default handles the rest; case nil matches a nil interface value.
Multiple types in one case
switch v := x.(type) {
case int, int64:
// v has type any (interface{}) here — common type of the list
fmt.Println(v)
}When multiple types share a case, v reverts to the interface type of the switch expression.
Prefer type switch over assertion ladders
// brittle
if x, ok := v.(A); ok { ... } else if y, ok := v.(B); ok { ... }
// clearer
switch v := v.(type) {
case A: ...
case B: ...
}Theory 3 — The nil interface vs typed nil
Interface representation (conceptual)
An interface value is a pair (type, value). It is == nil only when both are unset.
var p *os.File // p == nil
var r io.Reader
fmt.Println(r == nil) // true
r = p // store (*os.File, nil)
fmt.Println(r == nil) // false
fmt.Println(p == nil) // trueCalling methods may still panic if they dereference:
// r.Read(...) // may panic depending on methodThe error trap
type MyError struct{ Msg string }
func (e *MyError) Error() string { return e.Msg }
func mightFail(fail bool) error {
var err *MyError
if fail {
err = &MyError{Msg: "boom"}
}
return err // BUG when fail==false: returns typed nil
}
func main() {
err := mightFail(false)
if err != nil {
fmt.Println("looks like error:", err) // THIS RUNS — surprising!
}
}Correct patterns
func mightFail(fail bool) error {
if fail {
return &MyError{Msg: "boom"}
}
return nil // untyped nil → nil interface
}Or:
var err error // interface type
if fail {
err = &MyError{Msg: "boom"}
}
return err // stays nil interface when not setLaw: never return a nil concrete pointer as an interface type. Return plain nil.
Theory 4 — Assertions for optional capabilities
func writeAll(w io.Writer, p []byte) error {
_, err := w.Write(p)
if err != nil {
return err
}
if f, ok := w.(interface{ Flush() error }); ok {
return f.Flush()
}
return nil
}Or use stdlib interfaces:
if c, ok := w.(io.Closer); ok {
return c.Close()
}This is idiomatic and better than forcing every writer to implement flush.
Theory 5 — any from JSON / maps (caution)
var m map[string]any
_ = json.Unmarshal(data, &m)
switch v := m["count"].(type) {
case float64: // JSON numbers → float64 by default
fmt.Println(int(v))
case string:
// ...
default:
// missing or wrong
}Prefer typed structs when schema is known. Assertions on any are a last resort.
Theory 6 — Reflect? Not today
reflect can inspect types dynamically. It is powerful and easy to abuse. For this volume, prefer:
- Interfaces
- Type switches
errors.As(tomorrow)
Reach for reflect only when writing codecs/frameworks.
Worked examples bank
Example A — Safe assert helpers
func asString(v any) (string, bool) {
s, ok := v.(string)
return s, ok
}
func mustString(v any) string {
s, ok := v.(string)
if !ok {
panic(fmt.Sprintf("want string got %T", v))
}
return s
}Prefer asString in libraries; must* only in tests or main wiring.
Example B — Demonstrate typed nil
package main
import "fmt"
type E struct{}
func (E) Error() string { return "E" }
func bad() error {
var p *E
return p
}
func good() error {
return nil
}
func main() {
fmt.Println("bad nil?", bad() == nil) // false
fmt.Println("good nil?", good() == nil) // true
}Example C — Type switch router
type Event any // pedagogical; prefer sum types via interfaces in real design
type Login struct{ User string }
type Logout struct{ User string }
type Ping struct{}
func handle(ev Event) string {
switch e := ev.(type) {
case Login:
return "welcome " + e.User
case Logout:
return "bye " + e.User
case Ping:
return "pong"
case nil:
return "empty"
default:
return fmt.Sprintf("unknown %T", e)
}
}Example D — Interface upgrade
func copyN(dst io.Writer, src io.Reader, n int64) (int64, error) {
return io.CopyN(dst, src, n)
}
// If you need ReaderAt:
func readAtLeast(ra io.ReaderAt, p []byte) (int, error) {
return ra.ReadAt(p, 0)
}Assert only when optional:
if ra, ok := r.(io.ReaderAt); ok {
return readAtLeast(ra, buf)
}
return io.ReadFull(r, buf)Example E — Package-level compile check still works
type Flusher interface {
Flush() error
}
type Buf struct{}
func (b *Buf) Flush() error { return nil }
var _ Flusher = (*Buf)(nil)Labs
Suggested workspace: ~/lab/90daysofx/go/day12
Lab 1 — Typed-nil museum
mkdir -p ~/lab/90daysofx/go/day12
cd ~/lab/90daysofx/go/day12
go mod init example.com/day12Write a program that prints:
- Nil interface == nil
- Typed nil pointer in interface != nil
- Fixed version returning true nil
Put expected output in comments.
Lab 2 — Safe assertion helpers package
internal/cast or cast package:
func String(v any) (string, error)
func Int(v any) (int, error) // accept int and int64; reject float without truncate policyTable of inputs in main or a test file.
Lab 3 — Capability assert
Write func CloseIfNeeded(x any) error that:
- If
ximplementsio.Closer, callClose
- Else return nil
- If
xis nil interface, return nil
Lab 4 — Type switch CLI
Args: values encoded simply (int:3, str:hi, bool:true). Parse to any, type-switch to print normalized form. Unknown prefix → error.
Common gotchas
| Gotcha | Fix |
|---|---|
| Single-value assert panic | Use comma-ok |
Typed nil as error |
Return plain nil |
case int, int64 expecting typed v |
v becomes interface |
| Asserting on non-interface | Illegal; need any/interface |
Overusing any + asserts |
Prefer real interfaces/structs |
Forgetting case nil |
Handle explicitly if needed |
Checkpoint
- Comma-ok assertion habit
- Type switch written from memory
- Can explain why typed nil breaks
err != nil
- Fixed a bad error return pattern
- Optional capability assert implemented
- Helpers return errors instead of panicking
Commit
git add .
git commit -m "day12: type assert + nil interface gotcha"Tomorrow
Day 13 — Errors: wrap, Is, As, Join: building inspectable validation errors with fmt.Errorf("%w"), errors.Is / As / Join, and sentinels—stdlib only.
Day 13 — Errors wrap Is As Join
Stage II · ~3h (theory-heavy)
Goal: Treat errors as values you can wrap, inspect, and join—using fmt.Errorf with %w, errors.Is, errors.As, and errors.Join—then build a small validation library with inspectable failures.
Since Go 1.13, wrapping is first-class. Go 1.20 added errors.Join. Your 1.26 baseline includes all of this; use it instead of string-matching error messages.
Why this day exists
Call sites need different questions:
- What failed? (message for humans/logs)
- Is this a known condition? (retry? 404? usage?)
- What structured details? (field name, code)
- Did multiple things fail? (batch validation)
Panic is not the answer (Day 14). String equality on err.Error() is brittle. Day 13 is production Go’s error culture.
Theory 1 — The error interface
type error interface {
Error() string
}Any type with Error() string is an error. Stdlib constructors:
errors.New("not found")
fmt.Errorf("open %s: %v", path, err) // %v embeds message only (no wrap)
fmt.Errorf("open %s: %w", path, err) // %w wraps for Is/AsSentinel errors
var ErrNotFound = errors.New("not found")
func Find(id string) error {
return fmt.Errorf("user %s: %w", id, ErrNotFound)
}
if errors.Is(err, ErrNotFound) {
// handle
}Sentinels are package-level variables. Export them when callers must branch.
Theory 2 — Wrapping with %w
func load(path string) error {
_, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
return nil
}Wrapping:
- Adds context for humans
- Preserves the chain for
errors.Is/errors.As
- Can be unwrapped with
errors.Unwrap
Do not wrap twice carelessly with %v
return fmt.Errorf("load: %v", err) // loses Is/As chain (pre-wrap style)Prefer %w when the cause is still programmatically meaningful.
Opaque wrap (hide cause from Is)
Sometimes you intentionally do not use %w to avoid leaking or matching lower errors—rare; document why.
Theory 3 — errors.Is
if errors.Is(err, os.ErrNotExist) {
// file missing
}
if errors.Is(err, ErrNotFound) {
// our sentinel
}Is walks the wrap chain (and custom Is methods). Use it instead of err == ErrX when wrapping may exist.
// fragile if wrapped
if err == ErrNotFound { }
// robust
if errors.Is(err, ErrNotFound) { }Theory 4 — errors.As
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("path:", pathErr.Path)
}As finds the first error in the chain assignable to the target type.
Custom error types
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, &ValidationError{Field: "age", Msg: "must be integer"}
}
if n < 0 {
return 0, &ValidationError{Field: "age", Msg: "must be >= 0"}
}
return n, nil
}
// caller
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println(ve.Field, ve.Msg)
}Prefer pointer error types when using As with pointers (consistent with stdlib *fs.PathError).
Theory 5 — errors.Join (Go 1.20+)
var errs []error
if e := checkName(n); e != nil {
errs = append(errs, e)
}
if e := checkAge(a); e != nil {
errs = append(errs, e)
}
return errors.Join(errs...)Join:
- Returns
nilif all are nil
- Combines multiple errors
errors.Is/Ascan match any joined error
err := errors.Join(ErrA, ErrB)
errors.Is(err, ErrA) // true
errors.Is(err, ErrB) // trueIdeal for validation: report all field problems, not only the first.
Theory 6 — Wrapping + Join + Is together
err := validate(user)
if err != nil {
return fmt.Errorf("register: %w", err)
}
// caller still errors.Is(err, ErrWeakPassword)Printing
fmt.Printf("%v\n", err) // usually single-line chain depending on type
fmt.Printf("%+v\n", err) // some libraries add stacks; stdlib Join formats multi-lineDo not depend on exact string format in tests—use Is/As.
Theory 7 — Design guidelines
| Practice | Why |
|---|---|
| Add context when crossing package/IO boundaries | Debuggability |
| Keep sentinels stable | API contract |
Prefer As for structured data |
Avoid parsing messages |
Join for multi-error validation |
Better UX |
| Do not panic for validation | Caller’s choice |
Avoid _ = err |
Silence is a bug |
HTTP mapping preview (later stages)
switch {
case errors.Is(err, ErrNotFound):
// 404
case errors.As(err, &ve):
// 400
default:
// 500
}Worked examples bank
Example A — Sentinel + wrap
package users
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("user not found")
func Lookup(db map[string]string, id string) (string, error) {
name, ok := db[id]
if !ok {
return "", fmt.Errorf("lookup %q: %w", id, ErrNotFound)
}
return name, nil
}Example B — Validation with Join
package validate
import (
"errors"
"fmt"
"strings"
"unicode/utf8"
)
type FieldError struct {
Field string
Msg string
}
func (e *FieldError) Error() string {
return e.Field + ": " + e.Msg
}
func Email(s string) error {
if s == "" {
return &FieldError{"email", "required"}
}
if !strings.Contains(s, "@") {
return &FieldError{"email", "must contain @"}
}
return nil
}
func Password(s string) error {
if utf8.RuneCountInString(s) < 8 {
return &FieldError{"password", "min length 8"}
}
return nil
}
func User(email, pass string) error {
return errors.Join(Email(email), Password(pass))
}
func HasField(err error, field string) bool {
var fe *FieldError
if errors.As(err, &fe) && fe.Field == field {
return true
}
// Join may contain multiple; walk with unwrap helpers or iterate:
// for Go 1.20+ Join, As finds first match — for all fields, custom visitor:
return fieldInJoin(err, field)
}
func fieldInJoin(err error, field string) bool {
if err == nil {
return false
}
var fe *FieldError
if errors.As(err, &fe) && fe.Field == field {
return true
}
// When multiple FieldErrors joined, As finds one; for exhaustive listing:
u, ok := err.(interface{ Unwrap() []error })
if !ok {
return false
}
for _, e := range u.Unwrap() {
if fieldInJoin(e, field) {
return true
}
}
return false
}Example C — Is with os errors
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("config %s missing: %w", path, err)
}
if err != nil {
return err
}Example D — Custom Is method (optional advanced)
type TimeoutError struct{ Seconds int }
func (e *TimeoutError) Error() string {
return fmt.Sprintf("timeout after %ds", e.Seconds)
}
func (e *TimeoutError) Is(target error) bool {
_, ok := target.(*TimeoutError)
return ok // treat any TimeoutError as matching — simplified
}Usually sentinels + As are enough; custom Is is for special equality.
Example E — Main exit mapping
func main() {
err := run(os.Args[1:])
if err == nil {
return
}
fmt.Fprintln(os.Stderr, err)
switch {
case errors.Is(err, ErrUsage):
os.Exit(2)
case errors.Is(err, ErrNotFound):
os.Exit(1)
default:
os.Exit(1)
}
}Labs
Suggested workspace: ~/lab/90daysofx/go/day13
Lab 1 — Validation library
mkdir -p ~/lab/90daysofx/go/day13
cd ~/lab/90daysofx/go/day13
go mod init example.com/day13Package validate (name free):
- Field-level errors with
Field+Msg
- Validators for at least: non-empty string, min length, int range, email-ish rule
ValidatePerson(name, email, age string) errorusingerrors.Join
- CLI that prints each error line and exits non-zero if any
Lab 2 — Inspect with Is/As
In tests or a demo:
err := ValidatePerson("", "bademail", "9000")
// As into *FieldError at least once
// Custom helper lists all field names that failedLab 3 — Wrap across boundary
cmd package loads “user input” and wraps validation:
return fmt.Errorf("register user: %w", err)Show errors.Is / field detection still works through the wrap.
Lab 4 — Compare bad practices
Deliberately write one function that string-contains err.Error() and note why it is fragile. Replace with Is/As.
Common gotchas
| Gotcha | Fix |
|---|---|
err == sentinel with wraps |
errors.Is |
%v instead of %w |
Use %w to preserve chain |
| Returning typed nil error | Day 12—return nil |
| Matching on message text | Sentinels / types |
| Only first validation error | errors.Join |
| Exporting unstable sentinels | Treat as API |
Ignoring Join nil behavior |
Join of all nils is nil |
Checkpoint
%wwrap used at a boundary
errors.Isfor a sentinel
errors.Asfor a structured type
errors.Joinfor multi-field validation
- No panic for bad input
- CLI surfaces useful messages
Commit
git add .
git commit -m "day13: errors wrap Is As Join validation"Tomorrow
Day 14 — Panic & recover: when panic is appropriate (and when it is not), how recover works at boundaries, and writing a safe-call wrapper without turning Go into exception-oriented code.