Day 5 — Interfaces, Type Assertions & Errors

Updated

July 30, 2025

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.

Note

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:

  1. A dynamic type
  2. 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 Person

Compile-time check without assigning:

var _ Greeter = Person{}   // value methods OK
var _ Greeter = (*Person)(nil) // if methods are on *Person, use pointer form

Method 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{}   // OK

Theory 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 interface

This 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 applicable

Example 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/day11

Take a Day 10-style domain (todo, inventory, or users) and:

  1. Define a small store interface in the package that consumes it (or a service package).
  2. Provide an in-memory concrete implementation.
  3. Wire the concrete type in main only.
  4. 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 T vs *T clear
  • 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.

Note

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 any from 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

  1. i must be an interface value (or type parameter constrained appropriately).
  2. Assertion fails if dynamic type is not Concrete / not assignable to it.
  3. 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=0

Theory 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) // true

Calling methods may still panic if they dereference:

// r.Read(...) // may panic depending on method

The 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 set

Law: 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/day12

Write a program that prints:

  1. Nil interface == nil
  2. Typed nil pointer in interface != nil
  3. 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 policy

Table of inputs in main or a test file.

Lab 3 — Capability assert

Write func CloseIfNeeded(x any) error that:

  • If x implements io.Closer, call Close
  • Else return nil
  • If x is 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.

Note

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/As

Sentinel 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 nil if all are nil
  • Combines multiple errors
  • errors.Is / As can match any joined error
err := errors.Join(ErrA, ErrB)
errors.Is(err, ErrA) // true
errors.Is(err, ErrB) // true

Ideal 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-line

Do 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/day13

Package validate (name free):

  1. Field-level errors with Field + Msg
  2. Validators for at least: non-empty string, min length, int range, email-ish rule
  3. ValidatePerson(name, email, age string) error using errors.Join
  4. 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 failed

Lab 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

  • %w wrap used at a boundary
  • errors.Is for a sentinel
  • errors.As for a structured type
  • errors.Join for 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.