Type Switches

A detailed guide to type switches in Go, covering syntax, mechanics, common pitfalls, and real-world usage when working with interface values.

A type switch is a control structure that lets you branch on the concrete type stored inside an interface value. Instead of writing multiple if statements with type assertions, you write a single switch statement where each case specifies a type. The first matching type triggers its block.

This is not a switch on values — it is a switch on the dynamic type of the interface.

What Problem Type Switches Solve

Interfaces hide the concrete type behind a set of methods. When you receive an interface{} (the empty interface) or any interface value that could hold many different types, you eventually need to know what it really is to do something useful: format it, perform arithmetic on it, serialize it, or dispatch it to a type‑specific handler.

You could write:

if v, ok := x.(int); ok {
    // use v as int
} else if v, ok := x.(string); ok {
    // use v as string
} else {
    // unknown type
}

That gets noisy with more than a couple of types. A type switch gives you the same power in a cleaner, flatter shape, and it communicates to the reader immediately that this code handles a closed set of known types.

How a Type Switch Works

The syntax is:

switch v := x.(type) {
case T1:
    // v has type T1 here
case T2:
    // v has type T2 here
default:
    // v has the same type as x
}

The special keyword type tells the compiler this is a type switch, not a value switch. The short declaration v := x.(type) creates a variable v that, inside each matching case, holds the value of x with the specific case type, not the original interface type. In the default branch, v keeps the original interface type and value of x.

Here is a small, runnable example:

main.go
package main
import "fmt"
func describe(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Printf("Integer: %d (double is %d)\n", v, v*2)
    case string:
        fmt.Printf("String: %q (length %d)\n", v, len(v))
    case bool:
        fmt.Printf("Boolean: %t\n", v)
    default:
        fmt.Printf("Unknown type: %T\n", v)
    }
}
func main() {
    describe(42)
    describe("hello")
    describe(true)
    describe(3.14)
}

Running this prints:

Integer: 42 (double is 84)
String: "hello" (length 5)
Boolean: true
Unknown type: float64

Inside case int, v is of type int, so v*2 compiles and works. Inside default, v is still interface{}, so we can only use %T to print its type. The same variable name v is reused across cases, but each case block gets a variable with a different static type.

Type narrowing inside cases:

The variable declared in the switch statement is automatically narrowed to the concrete type in each case. You do not need another type assertion inside the case block.

The Declaration Syntax in Detail

You can write the switch statement without capturing the value if you only need the type information and not the concrete value:

func kind(x interface{}) string {
    switch x.(type) {
    case int, int8, int16, int32, int64:
        return "integer family"
    case float32, float64:
        return "float family"
    default:
        return "other"
    }
}

When you do capture a variable with switch v := x.(type), the variable v is scoped to the entire switch block. Each case reuses that variable name but with a different type. This is not variable shadowing in the traditional sense — it is a single declaration that the compiler understands as having a case‑dependent type.

If you need the value only in some cases but not others, you can still capture it and use _ in branches where it is unused.

Multiple Types in a Single Case

You can group types by separating them with commas:

func isNumber(x interface{}) bool {
    switch v := x.(type) {
    case int, int8, int16, int32, int64,
        float32, float64:
        fmt.Println("Numeric value:", v)
        return true
    default:
        return false
    }
}

However, there is a catch: when you list more than one concrete type in a single case, the variable v remains of the original interface type, not one of the listed concrete types. The compiler cannot pick one concrete type to assign to v, so it leaves it as the interface.

This means you cannot directly write arithmetic on v inside that case, even though you know it is numeric:

case int, float64:
    // v + 1  ← compile error: v is interface{}

No type narrowing with multiple case types:

Grouping several types in one case does not narrow the variable to a common concrete type. If you need to operate on the concrete value, write a separate case for each type, or perform a nested type assertion inside the block.

To work around this, you can either split the case:

case int:
    fmt.Println(v + 1)
case float64:
    fmt.Println(v + 1.0)

Or perform a second type switch / assertion inside the combined case, though that is rarely cleaner.

Type Switch vs Single Type Assertion

A type assertion v, ok := x.(T) is the right tool when you care about exactly one type. A type switch is the right tool when you must handle several types in an if‑else‑like chain.

The two patterns can coexist. A common technique is to use a type switch for the main dispatch, then perform further assertions inside a case when you need to refine the type further — for example, when you have a case for error and then check for a specific concrete error type.

No performance penalty for small switches:

The compiler implements a type switch as a linear scan over the listed types. For the typical handful of cases in production code this is extremely fast and rarely a concern.

Fallthrough Is Not Allowed

In a regular switch statement you can use fallthrough to continue into the next case. In a type switch, fallthrough is prohibited. The compiler will reject it. The reason is that the next case might expect a variable of a completely different type, which would break Go’s type safety. If you need to execute the logic of the next case, call a shared function from both cases.

fallthrough causes a compile error:

Attempting to use fallthrough inside a type switch results in: cannot fallthrough in type switch.

Type Switches and nil Interfaces

A nil interface (both the value and the type are nil) behaves predictably in a type switch: it matches no concrete case and falls through to default. The variable v in the switch declaration will be nil of the original interface type.

func check(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Println("int:", v)
    default:
        fmt.Printf("default: v is nil? %v\n", v == nil) // true
    }
}
func main() {
    check(nil) // prints: default: v is nil? true
}

A more subtle trap is passing an interface that holds a typed nil pointer. The interface itself is not nil (it has type information), so the type switch matches the pointer case:

var p *int = nil
var i interface{} = p
switch v := i.(type) {
case *int:
    fmt.Println("*int case, v is nil:", v == nil) // true, but case matched
}

Typed nil != nil interface:

An interface containing a nil pointer is not a nil interface. The type switch will match the concrete type, and the variable inside the case will hold that nil pointer. This is a frequent source of nil pointer dereferences if you call methods on the extracted value without checking for nil.

Real‑World Usage

Parsing dynamic JSON

When you unmarshal JSON into an interface{}, you get a hierarchy of map[string]interface{} and []interface{}. A type switch is the standard way to traverse the structure:

func printValue(v interface{}) {
    switch val := v.(type) {
    case map[string]interface{}:
        for k, v := range val {
            fmt.Printf("key: %s\n", k)
            printValue(v)
        }
    case []interface{}:
        for _, elem := range val {
            printValue(elem)
        }
    case string:
        fmt.Println("string:", val)
    case float64:
        fmt.Println("number:", val)
    case bool:
        fmt.Println("bool:", val)
    default:
        fmt.Println("unknown")
    }
}

This recursive pattern powers many JSON explorers and dynamic data processors.

Error type inspection

Standard library functions sometimes return the error interface, but you care about a specific concrete error. A type switch lets you handle known error types while keeping the default for unexpected ones:

func handle(err error) {
    switch e := err.(type) {
    case *os.PathError:
        fmt.Println("File error:", e.Path)
    case *net.OpError:
        fmt.Println("Network error:", e.Op)
    default:
        fmt.Println("Other error:", err)
    }
}

Building lightweight generic helpers

Before generics, type switches were the primary way to write functions that operate on a limited set of numeric types. Even with generics available today, you might still encounter or choose this pattern for simplicity:

func square(num interface{}) interface{} {
    switch v := num.(type) {
    case int:
        return v * v
    case float64:
        return v * v
    default:
        panic("unsupported type")
    }
}

The trade‑off here is loss of compile‑time safety, but the code is straightforward.

Common Mistakes

  • Relying on type narrowing with multi‑type cases. As described earlier, case int, float64: leaves the variable as interface{}. Always test for this early when you group types.
  • Forgetting that a type switch over interface{} does not handle generic numeric operations well. The switch gives you concrete types, but you must still duplicate logic for int, float64, etc., unless you use reflection or generics.
  • Assuming a nil interface will match a pointer case. A nil interface{} is not a *int or any other pointer. It only matches default.
  • Using a type switch where a method set would suffice. If all the concrete types you care about implement a common interface, define that interface and call the method directly. A type switch often signals that polymorphism could have been used instead.
  • Writing enormous type switches with dozens of cases. That usually indicates a design problem; consider whether the set of handled types can be narrowed, or whether the logic should be pushed into the types themselves via an interface.

Best Practices

  • Include a default case. Even if you think you have covered every possible type, a default protects against future code changes and clarifies intent.
  • Keep cases focused. Extract large case bodies into helper functions.
  • Consider whether a type switch is really necessary. If you are switching on a closed set of types that you control, an interface with a method often yields cleaner code.
  • Be explicit about nil handling. If your function accepts an interface that might be nil or contain a nil pointer, decide early whether to handle it in a dedicated case or in default.
  • Use type switches near the boundary of your system. They are common in serialization, network message parsing, and plugin systems — places where you genuinely do not know the concrete type until runtime.

Summary

Type switches give you a readable, safe way to inspect the concrete type inside an interface. They sit between the blunt single‑type assertion and the heavy machinery of reflection, and they strike a good balance for most practical type‑dispatching needs.

The key insight is that a type switch is just a syntactic layer over a sequence of type assertions, with the compiler guaranteeing that each case block sees a variable of exactly the right type. Understanding that guarantee — and the few places it breaks down, like multi‑type cases — turns type switches from a syntax mystery into a predictable tool.

If you have not already, the companion section on type assertions covers the underlying mechanism. Together, type assertions and type switches form the complete toolkit for working with Go’s interfaces at runtime.