Type Switches

A type switch is a special form of Go’s switch statement that branches on the concrete, dynamic type stored inside an interface value instead of on an ordinary value. It reads like an ordinary switch but each case lists a type instead of a value, letting you handle several possible underlying types with one clean construct. Type switches are the idiomatic replacement for a chain of individual type assertions, and they show up constantly wherever Go code deals with any or with narrower interfaces such as error or fmt.Stringer.

Overview / How It Works

To understand a type switch, you first need to understand what an interface value actually is at runtime. An interface value in Go is not just a pointer to some data — it is a small header containing two words: a pointer to a type descriptor (which type is stored) and a pointer (or small value) holding the underlying data. When you assign an int, a string, or a custom struct to a variable of interface type such as any, Go boxes the concrete value together with a descriptor of its type. This is exactly why interfaces in Go are satisfied implicitly: there is no implements keyword, because “satisfying an interface” simply means “having the right method set” — the compiler checks this structurally at compile time, and the type descriptor is what lets the runtime recover the concrete type later.

A regular type assertion, v, ok := x.(T), asks a single yes/no question: “does x currently hold a T?” A type switch generalizes this into a multi-way branch: it inspects the type descriptor stored in the interface value once, and compares it in order against each case, running the first block whose type matches. This is more efficient and far more readable than writing a long if/else if chain of individual assertions, and the compiler can also catch mistakes (like an unreachable duplicate case) that a hand-rolled chain would not.

Type switches are used heavily in real Go code: decoding JSON into any and then branching on whether a field is a string, float64, or map[string]any; implementing a String()-like formatter for a family of AST or shape types; or writing generic-looking utility functions before Go had generics (and still today, since a type switch can dispatch on runtime type in ways compile-time generics cannot).

Syntax

switch v := x.(type) {
case T1:
    // v has type T1 here
case T2, T3:
    // v has the type of x (not narrowed) because two types are listed
case nil:
    // x is a nil interface value (no type, no value)
default:
    // no case matched; v has the type of x
}
Part Meaning
x An expression of interface type (commonly any, or a narrower interface like error).
v := x.(type) The special type-switch guard. v is a new variable, re-declared with a different concrete type inside each single-type case.
case T1: Matches when x‘s dynamic type is exactly T1. Inside this block, v has type T1.
case T2, T3: Matches either type. Because more than one type is listed, Go cannot narrow v, so it keeps the original interface type.
case nil: Matches only when the interface value itself is nil (no type and no value stored).
default: Runs when no case matched; v keeps the original interface type.

The v := x.(type) part is unique syntax — it is only legal directly inside a switch header, never as a standalone statement. You can also omit the variable (switch x.(type) { ... }) if you only care about which branch runs and don’t need the narrowed value.

Examples

Example 1: Basic type switch over any

package main

import "fmt"

func describe(i any) {
	switch v := i.(type) {
	case int:
		fmt.Printf("int: %d\n", v)
	case string:
		fmt.Printf("string: %q\n", v)
	case bool:
		fmt.Printf("bool: %t\n", v)
	default:
		fmt.Printf("unknown type: %T\n", v)
	}
}

func main() {
	describe(42)
	describe("hello")
	describe(true)
	describe(3.14)
}
int: 42
string: "hello"
bool: true
unknown type: float64

Each call passes a different concrete type boxed into the any parameter i. Inside each matched case, v is automatically re-typed — in the int case, v really is an int, so %d works directly with no further conversion. The float64 value falls through to default, where v keeps its original any type, so %T is used to print what it actually was.

Example 2: Grouped types and the nil case

package main

import "fmt"

func classify(i any) string {
	switch i.(type) {
	case nil:
		return "nil value"
	case int, int32, int64:
		return "integer"
	case float32, float64:
		return "floating point"
	case string:
		return "string"
	default:
		return "other"
	}
}

func main() {
	fmt.Println(classify(10))
	fmt.Println(classify(2.5))
	fmt.Println(classify("go"))
	fmt.Println(classify(nil))
	fmt.Println(classify(true))
}
integer
floating point
string
nil value
other

Here the switch value itself isn’t used, so we write i.(type) without a v := assignment. The case int, int32, int64: line groups three related types into one branch — useful when several types should be treated identically. The case nil: branch only matches when classify(nil) is called with a literal nil interface, which is the fourth call.

Example 3: Dispatching over an interface with methods

package main

import (
	"fmt"
	"math"
)

type Shape interface {
	Area() float64
}

type Circle struct {
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * c.Radius * c.Radius
}

type Rectangle struct {
	Width, Height float64
}

func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

func describeShape(s Shape) string {
	switch shape := s.(type) {
	case Circle:
		return fmt.Sprintf("circle with radius %.1f, area %.2f", shape.Radius, shape.Area())
	case Rectangle:
		return fmt.Sprintf("rectangle %.1fx%.1f, area %.2f", shape.Width, shape.Height, shape.Area())
	default:
		return fmt.Sprintf("unknown shape with area %.2f", shape.Area())
	}
}

func main() {
	shapes := []Shape{
		Circle{Radius: 2},
		Rectangle{Width: 3, Height: 4},
	}
	for _, s := range shapes {
		fmt.Println(describeShape(s))
	}
}
circle with radius 2.0, area 12.57
rectangle 3.0x4.0, area 12.00

This is the most realistic use of a type switch: describeShape accepts any Shape, but wants to print type-specific details (Radius, or Width/Height) that aren’t part of the Shape interface itself. The type switch recovers the concrete struct so the extra fields become accessible, while the default branch still safely falls back to the interface’s own Area() method for any future Shape implementation.

How It Works Step by Step

  • The switch expression x is evaluated exactly once, producing an interface value (a type descriptor plus data).
  • Go compares the stored type descriptor against each case‘s type(s), top to bottom, exactly like an ordinary switch compares values.
  • On the first match, that block runs. Unlike C-style switches, Go’s switch (type or ordinary) does not fall through by default — there is no need for a break, and in fact fallthrough is not permitted at all inside a type switch, because the next case has a different static type for v and “falling through” to it wouldn’t type-check.
  • If exactly one type is listed in the matching case, the compiler gives v that concrete type for the rest of the block, so you can call type-specific methods or access type-specific fields directly, with no manual assertion needed.
  • If no case matches, the default block runs (if present); otherwise nothing happens and control falls out of the switch.

Common Mistakes

Mistake 1: Expecting a narrowed type from a grouped case

When a case lists more than one type, Go cannot pick a single concrete type for v, so v keeps the original interface type. Trying to use it as if it were narrowed is a compile error:

switch v := i.(type) {
case int, int64:
    fmt.Println(v + 1) // compile error: invalid operation, v is of type any
}

Fix it by giving each type its own case so v is narrowed in each one:

package main

import "fmt"

func describeNumber(i any) {
	switch v := i.(type) {
	case int:
		fmt.Println("int:", v+1)
	case int64:
		fmt.Println("int64:", v+1)
	default:
		fmt.Println("not a recognized integer type")
	}
}

func main() {
	describeNumber(5)
	describeNumber(int64(10))
	describeNumber("nope")
}
int: 6
int64: 11
not a recognized integer type

Mistake 2: A typed nil pointer is not a nil interface

A very common Go surprise: storing a nil pointer inside an interface produces an interface value that is not nil, because the interface still carries a non-nil type descriptor (it just points at nil data). A case nil: branch will not catch this:

package main

import "fmt"

type MyError struct{}

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

func mayFail(fail bool) *MyError {
	if fail {
		return &MyError{}
	}
	return nil
}

func run() error {
	e := mayFail(false)
	return e // returning a nil *MyError as error boxes a non-nil interface!
}

func main() {
	err := run()
	switch err.(type) {
	case nil:
		fmt.Println("no error")
	default:
		fmt.Println("got a non-nil interface, even though the pointer was nil")
	}
}
got a non-nil interface, even though the pointer was nil

The fix is to never return a typed nil pointer through an interface-typed return value; check for nil explicitly and return the untyped nil literal instead:

package main

import "fmt"

type MyError struct{}

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

func mayFail(fail bool) *MyError {
	if fail {
		return &MyError{}
	}
	return nil
}

func run() error {
	e := mayFail(false)
	if e == nil {
		return nil // a true nil interface
	}
	return e
}

func main() {
	err := run()
	switch err.(type) {
	case nil:
		fmt.Println("no error")
	default:
		fmt.Println("got an error")
	}
}
no error

Best Practices

  • Prefer a type switch over a chain of if v, ok := x.(T); ok assertions when you need to handle three or more possible types — it is clearer and the type check happens once.
  • Always include a default case when the set of possible types is open-ended (e.g. anything satisfying an interface written by other packages), so unexpected types are handled gracefully instead of silently doing nothing.
  • Put a case nil: first when a nil interface is a meaningful, distinct outcome you want to handle explicitly, rather than letting it fall into default.
  • Never return a nil pointer of a concrete type through an interface-typed return value (like error); check for nil and return the literal nil instead, to avoid the typed-nil trap.
  • Keep type switches focused on genuinely different behavior per type; if every case does almost the same thing, a shared interface method is usually a better design than dispatching by type.
  • Remember fallthrough cannot be used in a type switch — if two cases need identical logic, list their types together in one case instead.

Practice Exercises

  • Write a function sum(items []any) (float64, error) that uses a type switch to add up the numeric values in the slice (handle int, float64, and maybe int64), returning an error for any other type it encounters.
  • Write a jsonType(v any) string function that mimics how encoding/json decodes into any: it should distinguish nil, bool, float64, string, []any, and map[string]any, returning a descriptive string for each.
  • Define two struct types, Dog and Cat, each with a Speak() string method satisfying a common Animal interface. Write a function that takes an Animal and uses a type switch to print a species-specific greeting (e.g. "Woof from a dog!") in addition to calling Speak().

Summary

  • A type switch, switch v := x.(type) { ... }, branches on the dynamic type stored inside an interface value.
  • Each case lists one or more types; a single-type case narrows v to that concrete type, while a multi-type case leaves v as the original interface type.
  • case nil: matches only a truly nil interface (no type and no value) — a nil pointer boxed into an interface is not caught by it.
  • There is no fallthrough in a type switch, and no break is needed, matching ordinary Go switch behavior.
  • Type switches are the idiomatic way to recover concrete types from any or from narrower interfaces, and are far cleaner than chains of individual type assertions.