Back to Notes

Go Type Assertions & Generics

Type Assertions & Type Switches

What: Runtime extraction of the concrete type (or a narrower interface) from an interface value.

Syntax/Signature: v, ok := i.(T) (comma-ok, safe) · v := i.(T) (panics on mismatch) · switch v := i.(type) { case T1: ... }.

func size(w io.Writer) int64 {
	// Interface-to-interface assertion: capability probing.
	if f, ok := w.(interface{ Stat() (fs.FileInfo, error) }); ok {
		if fi, err := f.Stat(); err == nil {
			return fi.Size()
		}
	}
	return -1
}

func describe(v any) string {
	switch x := v.(type) {
	case nil:
		return "nil"
	case int, int64:
		return fmt.Sprintf("integer %v", x)
	case fmt.Stringer:
		return x.String()
	default:
		return fmt.Sprintf("%T", x)
	}
}

How it works under the hood: Concrete-type assertion compares tab._type (or eface._type) against the target type descriptor — a pointer compare via the cached hash (see [[Go Interface Internals]]). Interface-to-interface assertion must find/build an itab for the new pair (cached global lookup). In a type switch, case int, int64 binds x as the original interface type, not a union.

Interview angle: "Difference between the one-result and two-result forms?" — one-result panics; comma-ok returns zero value + false. Also: "How does errors.As differ from a type assertion?" — As walks the wrap chain (see [[Go Error Handling]]); a bare assertion checks only the outermost value.

Gotchas / common wrong answers:

  • Using i.(T) without ok in library code — a panic vector on untrusted input.
  • Type-switching on errors instead of errors.As — breaks the moment someone wraps the error.
  • case nil is needed to catch nil interfaces in a switch; default won't distinguish it.

Trade-offs / when NOT to use: Long type switches over your own types often mean you wanted a method on an interface (polymorphism) instead. Capability probing (io.WriterTo, http.Flusher) is the legitimate use.

any / the Empty Interface

What: any is an alias for interface{} (since 1.18) — satisfied by every type; conveys zero static information.

Syntax/Signature: func Println(a ...any) · as a generic constraint: func F[T any](v T).

// Legitimate: truly heterogeneous payloads.
var payload map[string]any
_ = json.Unmarshal(data, &payload)

// Better than any where the element type is uniform — generics:
func First[T any](s []T) (T, bool) {
	var zero T
	if len(s) == 0 {
		return zero, false
	}
	return s[0], true
}

How it works under the hood: Stored as eface{_type, data} — no method table, so it's the cheapest interface to convert to, but every use requires an assertion/switch back out. Non-pointer values still box (heap-allocate) on conversion.

Interview angle: "When is any still correct post-generics?" — heterogeneous containers (JSON trees), fmt/logging boundaries, context.Value keys/values. Uniform-type code should use type parameters.

Gotchas / common wrong answers:

  • Treating any in a constraint position and in a value position as the same mechanism — constraint any is compile-time, value any boxes at runtime.
  • map[string]any from JSON gives float64 for all numbers — asserting to int panics.
  • Comparing two any values holding uncomparable types (slices, maps, funcs) with == panics at runtime, even though it compiles.

Trade-offs / when NOT to use: any erases type safety and forces allocations + assertions. It's the tool of last resort at true dynamism boundaries, not a generics substitute.

Generics: Type Parameters & Constraints

What: Compile-time parametric polymorphism (1.18+): functions/types take type parameters restricted by constraints (interfaces with type sets, optionally ~-approximated).

Syntax/Signature: func F[T Constraint](v T) T · type Ordered interface { ~int | ~int64 | ~string } · comparable for map keys / ==.

type Ordered interface {
	~int | ~int64 | ~float64 | ~string
}

func Max[T Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

type Set[K comparable] struct{ m map[K]struct{} }

func NewSet[K comparable]() *Set[K] { return &Set[K]{m: map[K]struct{}{}} }
func (s *Set[K]) Add(k K)          { s.m[k] = struct{}{} }
func (s *Set[K]) Has(k K) bool     { _, ok := s.m[k]; return ok }

How it works under the hood: The gc compiler uses GCShape stenciling with dictionaries: one instantiation is compiled per shape (all pointer types share one shape; int and int64 differ), and a hidden dictionary argument supplies type-specific operations. Consequence: generic method calls through a constraint can still be indirect (dictionary lookup) — generics are not guaranteed to be as fast as hand-monomorphized code, but avoid interface boxing for values.

Interview angle: "Generics vs interfaces — when?" — generics when the relationship between types matters ([]T → T, homogeneous containers, avoiding boxing on hot paths); interfaces when you need heterogeneous runtime values or dependency seams. Also expect: "What's ~int?" — includes all types whose underlying type is int (named types like type UserID int).

Gotchas / common wrong answers:

  • Methods cannot have their own type parametersfunc (s *Set[K]) Map[V any](...) does not compile; use a package-level function.
  • You can't call union-type methods: constraints with unions only license operators (<, +); method calls require the method in every term or declared in the interface.
  • comparable permits interface-typed keys whose comparison can panic at runtime (comparable-but-not-strictly).
  • Zero value of T needs var zero T (or *new(T)) — there's no T{} for all shapes, and no nil unless constrained to pointers.

Trade-offs / when NOT to use: Don't genericize code with one concrete caller — it's abstraction debt. If you're constraining to a single method set with no operators, a plain interface parameter is simpler and idiomatic.

Interview Drills

  1. Interface parameter vs type parameter for func Process(items []Item) where Item varies — reason about performance and API. — With an interface ([]Doer + method calls): every element conversion may box (heap-allocate non-pointer values), every call is an indirect tab.fun[i] dispatch that blocks inlining, and the slice must literally be []Doer — callers with []MyItem must copy-convert O(n). With generics (func Process[T Doer](items []T)): no per-element boxing, callers pass []MyItem directly, and the compiler stencils per GCShape — though method calls via the constraint may still go through a dictionary, so it's not free monomorphization. Decision rule: homogeneous slice + hot path ⇒ generics; heterogeneous runtime mix or a dependency seam for testing ⇒ interface. Senior close: measure with benchmarks — escape analysis and PGO devirtualization can erase the expected gap.
  2. Why does map[string]any unmarshaled from JSON cause a panic when asserting a numeric field to int?encoding/json decodes all JSON numbers into any as float64, regardless of whether the original JSON literal looked like an integer — asserting directly to int fails because the underlying concrete type is float64, not int; convert via the float64 value instead.
  3. Why can't a method on a generic type itself introduce a new type parameter? — Go's generics design deliberately disallows generic methods (only generic functions and generic types are supported) — func (s *Set[K]) Map[V any](...) doesn't compile; the workaround is a package-level generic function taking the receiver's concrete type as an explicit argument instead.

Cross-links

[[Go Interface Internals]] · [[Go Data Structure Internals]] · [[Python OOP & Data Model]]