Type Switches in Go

Learn how type switches let you branch on the concrete type stored inside an interface value, with clear examples and common mistakes to avoid

A type switch is a specialized form of Go's switch statement that lets you inspect the underlying concrete type of an interface value. Instead of matching against specific values, each case in a type switch names a type — and the first case whose type matches the value held by the interface is the one that runs.

If you have already read about switch statements, the structure will feel familiar. The crucial difference is the expression that follows switch:

switch v := x.(type) {
case int:
    // v is an int here
case string:
    // v is a string here
default:
    // no match; v has the same type as x
}

The notation x.(type) is valid only inside a type switch. It tells the compiler to extract the dynamic type of the interface x and attempt to match it against the types listed in the case clauses.

Why Type Switches Exist

Interfaces in Go let you write code that works with values of many concrete types through a single abstract type. But at some point you usually need to know which concrete type an interface is holding — to format it differently, to validate it, or to dispatch to type-specific logic.

Before type switches, you could achieve this with a chain of type assertions:

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

This works, but it grows verbose and repetitive. A type switch collapses that chain into a single, readable block. It also automatically extracts the value once for the matching branch — no need for separate assertion variables in each if.

When an interface is necessary:

A type switch only makes sense when you are already working with an interface value — typically an interface{} (the empty interface, now often written as any) or a more specific interface. If you know the concrete type at compile time, you don't need a type switch; you can just call methods on that type directly.

How a Type Switch Works

At runtime, an interface value in Go contains two pieces of information: a pointer to the concrete type and a pointer to the actual data. When you write x.(type), the compiler generates code that inspects the concrete type and compares it against each case type in order.

The full syntax is:

switch optionalAssignment := x.(type) {
case TypeA:
    // optionalAssignment is of type TypeA here
case TypeB, TypeC:
    // optionalAssignment is an interface{} here
default:
    // optionalAssignment has the same type as x (the interface type)
}

A few rules govern the behavior:

  • The switched variable must be an interface. If x is a concrete type, the program won't compile.
  • Each case type must be a concrete type (not another interface), unless the case type is the same as the switched variable's static type — Go treats that as a concrete match.
  • The variable declared after switch (here optionalAssignment) holds the value inside the interface and, in the matching case, has the specific type of that case. In the default case, it retains the interface type of the original variable.
  • Fallthrough does not work in type switches. The language disallows it because falling into a case with a different type would break the type guarantees of the block.
  • The case evaluation is ordered. The first match wins. If you put a more general type before a specific one (e.g., any before int), the specific case can never be reached.

Fallthrough is forbidden in type switches:

Attempting to use fallthrough inside a type switch will cause a compile error. If you need shared behavior after type-specific logic, refactor that shared code into a function or execute it after the switch.

Type Switch vs. Type Assertion

Both mechanisms let you ask an interface about its concrete type, but they serve different needs.

ApproachBest for
Single type assertion v, ok := x.(T)When you expect exactly one concrete type and want to check it safely.
Type switchWhen the interface could hold several different types and you want to handle each one.

A type switch is essentially the compact version of an if-else chain of type assertions. It reduces duplication and improves readability when the number of possible types is two or more.

Here is the same logic expressed both ways — processing an unknown value from a JSON-like structure:

// Using type assertions
func describeAssert(v interface{}) string {
    if s, ok := v.(string); ok {
        return "string: " + s
    }
    if n, ok := v.(float64); ok {
        return fmt.Sprintf("number: %f", n)
    }
    if b, ok := v.(bool); ok {
        return fmt.Sprintf("bool: %t", b)
    }
    return "unknown"
}
// Using a type switch
func describeSwitch(v interface{}) string {
    switch val := v.(type) {
    case string:
        return "string: " + val
    case float64:
        return fmt.Sprintf("number: %f", val)
    case bool:
        return fmt.Sprintf("bool: %t", val)
    default:
        return "unknown"
    }
}

The type switch version eliminates the repeated ok checks and directly scopes val to the matching type. In the case string branch, val is already a string — no further assertion needed.

Cleaner code with less noise:

If your function needs to handle three or more types, a type switch nearly always produces shorter and clearer code than a chain of type assertions.

Practical Examples

Printing Type-Specific Information

A common pattern is a function that takes an interface{} and prints or logs details based on the concrete type. This example from the Go Tour is a classic starting point:

package main
import "fmt"
func do(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Printf("Twice %v is %v\n", v, v*2)
    case string:
        fmt.Printf("%q is %v bytes long\n", v, len(v))
    default:
        fmt.Printf("I don't know about type %T!\n", v)
    }
}
func main() {
    do(21)
    do("hello")
    do(true)
}

When you run this, you get:

Twice 21 is 42
"hello" is 5 bytes long
I don't know about type bool!

Inside the case int branch, v is typed as int, so you can perform arithmetic on it. Inside case string, v is a string, so len(v) works. In the default case, v remains an interface{}, but %T still reveals the concrete type at runtime.

Handling JSON Values

When you unmarshal arbitrary JSON into an interface{}, the standard library gives you Go types that mirror JSON types: float64 for numbers, string, bool, []interface{} for arrays, and map[string]interface{} for objects. A type switch makes it straightforward to traverse or format such structures:

func formatJSONValue(v interface{}) string {
    switch val := v.(type) {
    case nil:
        return "null"
    case string:
        return `"` + val + `"`
    case float64:
        return fmt.Sprintf("%g", val)
    case bool:
        return fmt.Sprintf("%t", val)
    case []interface{}:
        parts := make([]string, len(val))
        for i, elem := range val {
            parts[i] = formatJSONValue(elem)
        }
        return "[" + strings.Join(parts, ", ") + "]"
    case map[string]interface{}:
        pairs := make([]string, 0, len(val))
        for k, elem := range val {
            pairs = append(pairs, fmt.Sprintf("%q: %s", k, formatJSONValue(elem)))
        }
        return "{" + strings.Join(pairs, ", ") + "}"
    default:
        return fmt.Sprintf("%v", val)
    }
}

This function recursively descends through nested JSON structures, dispatching on the concrete type at each node. Without a type switch, the code would be littered with type assertions and temporary variables.

JSON numbers are always float64 by default:

When you decode a JSON number into an interface{}, Go's encoding/json package always produces a float64. If you later need to distinguish between integers and floating-point numbers, you'll need additional logic — a type switch can detect float64 but cannot tell whether the original JSON was an integer. Consider using json.NewDecoder with UseNumber() if you need more precision.

Declaring a Variable in the Type Switch

You don't always need to capture the unwrapped value. If you only care about the type itself — not the data inside — you can omit the variable name:

switch x.(type) {
case int:
    fmt.Println("x is an int")
case string:
    fmt.Println("x is a string")
}

When you do declare a variable, remember that its type changes from case to case. This means you cannot, for example, reuse v across multiple branches with the same method call unless all matched types share an interface. But inside each branch, v is the concrete type — which gives you full access to fields and methods specific to that type.

Matching Multiple Types in One Case

A single case can list several types separated by commas:

switch v := x.(type) {
case int, float64:
    fmt.Printf("numeric: %v\n", v)
case string, []byte:
    fmt.Printf("textual, length: %d\n", len(v)) // careful!
}

When you list multiple types, the variable v retains the interface type of the original expression — it cannot be narrowed to one concrete type because the compiler doesn't know which one will match. In the example above, v is still interface{} inside the first case, so you cannot call v * 2 on it directly. This is a deliberate design choice: if you need type-specific operations, give each type its own case or assert again inside the branch.

Comma-separated cases keep the interface type:

A case like case int, float64: does not give you a numeric type in the branch body. The variable remains the interface type (interface{}). If you want type-specific logic, write separate cases for int and float64.

Common Mistakes

Switching on a Non-Interface

A type switch is meaningless on a concrete value. This code won't compile:

x := 42
switch v := x.(type) { // compile error: cannot type switch on non-interface value
case int:
    fmt.Println(v)
}

You must first assign the value to an interface variable:

var i interface{} = 42
switch v := i.(type) {
case int:
    fmt.Println(v) // works
}

Expecting Fallthrough

As noted earlier, fallthrough is forbidden in type switches. If you need two branches to share code, extract that code into a helper or call it from both cases explicitly.

Forgetting That Each Case Has Its Own Scope

A variable declared with := inside a case is local to that case. A common misread is to assume a variable declared in one case is visible in another. It's not — each case is its own block.

Matching Against an Interface Type

You can write a case with an interface type, but the match checks if the concrete type inside the switched value implements that interface. This can be useful but often surprises beginners. For clarity, try to restrict case types to concrete types unless you intentionally want interface matching.

Real-World Usage

Type switches appear in many idiomatic Go codebases:

  • Error handling: When a function returns an error interface, you might use a type switch to distinguish different custom error types and extract structured information (e.g., a retry delay or a status code).
  • Middleware and HTTP handlers: An interface{} context value might be inspected to see whether it carries an *http.Request, a context.Context, or a custom user session type.
  • Pre-generics libraries: Before Go 1.18 introduced generics, type switches were the primary tool for writing functions that worked with multiple types through interface{} — for instance, a Contains function that checked membership in a slice of an unknown type.
  • Code generation and reflection tools: When walking through arbitrary data structures (as with the JSON example), type switches naturally dispatch on the concrete type at each step.

The type switch sits at the intersection of Go's static typing and the dynamic flexibility afforded by interfaces. It does not make Go a dynamically typed language — it makes working with the one place in Go where types are dynamic (interfaces) safer and more explicit.

Summary

A type switch gives you a clean, safe mechanism to inspect and branch on the concrete type behind an interface value. It replaces long chains of type assertions with a single control structure, reduces boilerplate, and keeps type-specific code scoped tightly to the matching branch.

The most important takeaway is that type switches work exclusively on interface values. If you find yourself writing one on a concrete type, step back and ask whether you really need an interface there — often you don't. When you do, the type switch becomes a lightweight form of dynamic dispatch that integrates naturally with Go's type system.