Day 12 — Type assertions & switches

Updated

July 30, 2026

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.