Skip to main content

Go from scratch, part 7: type assertions, type switches and the empty interface

> series: learn go from 0 to 100 | article 7 of 19

Last article interfaces were pure elegance. Today we visit the basement of Go type assertions: what happens when you need to recover the concrete type living inside an interface, what that ubiquitous any is, and the nil gotcha that has bitten every gopher at least once.


The empty interface and any

An interface with no methods, interface{}, demands nothing. Therefore ALL types satisfy it. Since Go 1.18, any is its official alias (Go 1.18 release notes) and the recommended spelling.

It’s the wildcard fmt.Println(a …any) uses to accept anything. But beware: putting a value into an any is easy, getting it out is the hard part. The compiler no longer knows what’s inside, and everything you gained from static typing gets paid back at the exit. Use it at the borders (JSON, logging, printing), not as a shortcut to avoid thinking about types.

Type assertion: opening the box

To recover the concrete type from an interface there’s the type assertion (A Tour of Go and spec):

var i any = "hello"

// Dangerous form: not a string? panic.
s := i.(string)

// Safe form: the comma ok.
s, ok := i.(string)
if !ok {
  fmt.Println("that was no string")
}

The comma ok form never panics: it returns the zero value and false when the type doesn’t match. Same comma ok you’ll see in maps and channels. In practice: always use the safe form unless you have absolute certainty about the type.

Type switch: asking about types in bulk

With more than two possible types, chained Go type assertions hurt. That’s what the type switch is for (A Tour of Go and spec):

func describe(x any) string {
  switch v := x.(type) {
    case int:
    return fmt.Sprintf("integer: %d", v)
    case string:
    return fmt.Sprintf("string of %d bytes", len(v))
    case []int:
    return fmt.Sprintf("slice with %d elements", len(v))
    case nil:
    return "a nil the size of a house"
    default:
    return fmt.Sprintf("no clue: %T", v)
  }
}

The x.(type) syntax only works inside a switch. In each branch, v already has the case’s concrete type: inside case int it’s a full-fledged int, no conversions. Coming from Java, this is instanceof with pattern matching, but from 2009. In Python it would be a chain of isinstance calls.

Go type assertions: Pixelart diagram illustrating Go type assertions: a sorting station where an any value enters a switch and exits through tracks labeled case int, case string and default
[type switch: Go’s very own type-sorting station.]

The nil-that-isn’t-nil gotcha

Brace yourself, here comes Go interviews’ favorite jump scare:

type MyError struct{}

func (e *MyError) Error() string { return "boom" }

func fails() error {
  var p *MyError = nil
  return p // here's the trap!
}

err := fails()
fmt.Println(err == nil) // false. Yes, false.

How can err == nil be false if we returned a nil pointer? Because an interface stores two things: type and value. Here the value is nil but the type is *MyError, so the interface is NOT nil. An interface is only nil when both type and value are nil (official FAQ, Effective Go).

The official moral: functions returning errors should declare error as the return type and return a literal nil, never a concrete pointer that might be nil. Keep this one warm, we’ll come back to it in article 9.


Weekly challenge: put Go type assertions to work

  • Write describe(x any) string with a type switch distinguishing at least five types, and test it with assorted values.
  • Reproduce the nil gotcha: copy the fails() code, verify the false, then fix it by returning an explicit nil.
  • Bonus: given a []any with mixed types, sum only the numbers (int and float64) using comma ok assertions.

Go type assertions: level complete.

Next article: slices, arrays and maps from the inside out. Hidden pointers, growing capacities, and the reason behind article 3‘s weird indexes.

> exit
Retrato pixel art de Jenniffer Cubillos

thanks for reading