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
xis 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 factfallthroughis not permitted at all inside a type switch, because the next case has a different static type forvand “falling through” to it wouldn’t type-check. - If exactly one type is listed in the matching case, the compiler gives
vthat 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
defaultblock 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); okassertions when you need to handle three or more possible types — it is clearer and the type check happens once. - Always include a
defaultcase 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 intodefault. - Never return a nil pointer of a concrete type through an interface-typed return value (like
error); check for nil and return the literalnilinstead, 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
fallthroughcannot be used in a type switch — if two cases need identical logic, list their types together in onecaseinstead.
Practice Exercises
- Write a function
sum(items []any) (float64, error)that uses a type switch to add up the numeric values in the slice (handleint,float64, and maybeint64), returning an error for any other type it encounters. - Write a
jsonType(v any) stringfunction that mimics howencoding/jsondecodes intoany: it should distinguishnil,bool,float64,string,[]any, andmap[string]any, returning a descriptive string for each. - Define two struct types,
DogandCat, each with aSpeak() stringmethod satisfying a commonAnimalinterface. Write a function that takes anAnimaland uses a type switch to print a species-specific greeting (e.g."Woof from a dog!") in addition to callingSpeak().
Summary
- A type switch,
switch v := x.(type) { ... }, branches on the dynamic type stored inside an interface value. - Each
caselists one or more types; a single-type case narrowsvto that concrete type, while a multi-type case leavesvas 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
fallthroughin a type switch, and nobreakis needed, matching ordinary Go switch behavior. - Type switches are the idiomatic way to recover concrete types from
anyor from narrower interfaces, and are far cleaner than chains of individual type assertions.
