Type switch in Go

A type switch in Go checks the dynamic type of an interface value and executes specific code blocks for each matched type.

When the box is opaque

You're building a JSON parser. The input is a map of strings to any. You grab a value, but the compiler only knows it's any. You can't call .Length() or .Add() because the compiler refuses to guess. You need to peel back the interface wrapper and ask: "What concrete type is hiding here?"

In Python, you'd reach for isinstance. In JavaScript, you'd check typeof or instanceof. Go has a different tool. Go uses a type switch to inspect the dynamic type of an interface value and route execution to the correct handler. The type switch opens the box, checks the label, and hands you the value with its full static type restored.

Interfaces hide types. Type switches reveal them.

How interfaces store secrets

Every interface value in Go carries two pieces of data: the concrete type and the concrete value. When you pass a string into a function expecting any, the compiler boxes that string up. The variable holds the box, not the string. The box contains a pointer to the type information and a pointer to the data.

A type switch reads the type pointer inside the box. It compares that type against a list of cases. When it finds a match, it unpacks the data pointer into a variable with the correct static type. This extraction lets you use the value normally inside the case block.

The syntax val.(type) looks like a method call, but it's a compiler keyword. You can only use it inside a switch statement. The compiler generates a dispatch mechanism that checks the type tag at runtime. If no case matches, the default case runs.

The type switch extracts the value and scopes it tightly.

Minimal example

Here's the simplest type switch: take an any, check the type, extract the value, and print it.

func describe(val any) {
    // The type switch syntax assigns the extracted value to v and checks its type.
    // v is scoped to the switch block, so its type changes per case.
    switch v := val.(type) {
    case int:
        // v is now an int. The compiler knows this inside the case block.
        fmt.Printf("int %d\n", v)
    case string:
        // v is now a string.
        fmt.Printf("string %q\n", v)
    case bool:
        // v is now a bool.
        fmt.Printf("bool %t\n", v)
    default:
        // v remains any here. Use %T to print the dynamic type for debugging.
        fmt.Printf("unknown type %T\n", v)
    }
}

The variable v is declared by the switch. Its type is determined by the case that matches. This scoping prevents type errors outside the switch. You can't accidentally use v as an int after the switch if the runtime value was a string.

Go 1.18 introduced any as an alias for interface{}. Use any for readability. The community prefers any in new code because it signals "this accepts anything" without the visual noise of empty braces.

The syntax extracts the value and scopes it tightly.

Walk through the execution

When the compiler sees switch v := val.(type), it performs several steps. First, it validates that val is an interface type. If val is a concrete type, the compiler rejects the program with invalid type switch expression val.(type).

At runtime, the switch reads the type tag from the interface header. It iterates through the cases. For each case, it compares the type tag against the case type. If the tags match, the switch performs a type assertion to extract the value. It assigns the value to v and jumps to the case body.

The variable v is declared in the switch scope. Its type is the type of the matching case. If the case is int, v is an int. If the case is string, v is a string. The compiler treats v as that type for the duration of the case block.

If you omit the assignment and write switch val.(type), you can still check types, but you can't extract the value. You'd have to use a separate type assertion inside each case, which is verbose and error-prone. Always use the assignment form v := val.(type) unless you only need the type check.

The compiler protects you from bad assignments. Trust the error messages.

Realistic example: JSON flattener

Real code often needs to handle multiple types gracefully. When unmarshaling JSON, you often get any values. Here's a helper that flattens a JSON structure by recursing through maps and slices, using a type switch to handle each container type.

func flattenJSON(key string, val any, result map[string]any) {
    // Recursive helper flattens nested maps and slices into a single level.
    // The type switch dispatches based on the container type.
    switch v := val.(type) {
    case map[string]any:
        // Recurse into maps, appending keys to the path.
        for k, innerVal := range v {
            flattenJSON(key+"."+k, innerVal, result)
        }
    case []any:
        // Recurse into slices, using index as key.
        for i, innerVal := range v {
            flattenJSON(fmt.Sprintf("%s[%d]", key, i), innerVal, result)
        }
    default:
        // Leaf value. Store it in the result map.
        // v is any here, but it's a concrete value like string, float64, or bool.
        result[key] = v
    }
}

This pattern is common in data processing pipelines. The type switch turns heterogeneous data into structured logic. Each case handles a specific shape of data. The default case catches leaf values and stores them.

Cases aren't limited to concrete types. You can list an interface in a case to check for capability. If you write case fmt.Stringer:, the switch matches any value that implements the String() method. The extracted variable v has type fmt.Stringer, so you can call v.String() immediately. This pattern combines type inspection with duck typing.

Type switches turn heterogeneous data into structured logic.

Pitfalls and compiler errors

Type switches have quirks that trip up developers coming from other languages.

The most common mistake is using val.(type) outside a switch. The compiler rejects this with invalid type switch expression val.(type). You must use the comma-ok idiom for single checks: if v, ok := val.(int); ok.

Another trap involves multiple types in a case. You can write case int, int64: to match either type. However, the variable v is typed as the first type in the list. If v is int and the runtime value is int64, the compiler complains with cannot use val (type int64) as int value in assignment. The assignment fails because int64 is not assignable to int without conversion. Avoid multiple types in a case unless the first type can hold the value of all listed types, or use separate cases for clarity.

Nil handling is subtle. An interface value is nil only if both its type and value are nil. If you store a nil pointer in an interface, the interface is not nil. It has a type. A type switch on that interface will match the pointer type, not case nil:. To catch nil pointers, you must check the pointer type explicitly or inspect the value after extraction.

var p *int = nil
var i any = p

// i is not nil. It has type *int and value nil.
switch v := i.(type) {
case *int:
    // This case matches. v is *int.
    // You must check v == nil to detect the nil pointer.
    if v == nil {
        fmt.Println("nil pointer")
    }
case nil:
    // This case never matches because i has a type.
    fmt.Println("nil interface")
}

The case nil: clause only matches when the interface itself is nil. It does not match nil pointers, nil slices, or nil maps stored in an interface.

The compiler protects you from bad assignments. Trust the error messages.

Decision matrix

Use a type switch when you have an any value and need to dispatch behavior based on its concrete type. Use a type assertion with the comma-ok idiom when you only need to check for one specific type and want a clean boolean result. Use generics when you can constrain the type at compile time instead of inspecting it at runtime. Use a method set on an interface when the types share behavior rather than structure. Use a type switch when you are parsing unstructured data like JSON and must handle heterogeneous values.

Generics push checks to compile time. Type switches handle runtime surprises.

Where to go next