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 nilis needed to catch nil interfaces in a switch;defaultwon'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
anyin a constraint position and in a value position as the same mechanism — constraintanyis compile-time, valueanyboxes at runtime. map[string]anyfrom JSON givesfloat64for all numbers — asserting tointpanics.- Comparing two
anyvalues 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 parameters —
func (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. comparablepermits interface-typed keys whose comparison can panic at runtime (comparable-but-not-strictly).- Zero value of
Tneedsvar zero T(or*new(T)) — there's noT{}for all shapes, and nonilunless 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
- Interface parameter vs type parameter for
func Process(items []Item)whereItemvaries — 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 indirecttab.fun[i]dispatch that blocks inlining, and the slice must literally be[]Doer— callers with[]MyItemmust copy-convert O(n). With generics (func Process[T Doer](items []T)): no per-element boxing, callers pass[]MyItemdirectly, 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. - Why does
map[string]anyunmarshaled from JSON cause a panic when asserting a numeric field toint? —encoding/jsondecodes all JSON numbers intoanyasfloat64, regardless of whether the original JSON literal looked like an integer — asserting directly tointfails because the underlying concrete type isfloat64, notint; convert via thefloat64value instead. - 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]]