Go Type Switches: Dynamic Type Dispatch

Go's type system walks a fine line between static typing and runtime flexibility. When you accept an `interface{}` or `any` parameter, you're telling the compiler 'I'll handle whatever type comes...

Key Insights

  • Type switches provide runtime type dispatch for interface values, enabling you to handle multiple concrete types with clean, readable syntax that’s more powerful than chained type assertions
  • Use type switches when you control the set of types being handled and need different logic per type; prefer interface methods when you want extensibility without modifying existing code
  • The performance overhead of type switches is minimal for most applications, but interface method dispatch is faster when you need polymorphic behavior across many types

Introduction to Type Switches

Go’s type system walks a fine line between static typing and runtime flexibility. When you accept an interface{} or any parameter, you’re telling the compiler “I’ll handle whatever type comes through.” But at some point, you need to actually work with the concrete type. That’s where type switches come in.

A type switch is Go’s idiomatic way to perform dynamic type dispatch—determining the concrete type of an interface value at runtime and executing different code paths accordingly. Unlike chained type assertions that quickly become unwieldy, type switches provide clean syntax for handling multiple types:

func process(value any) string {
    switch v := value.(type) {
    case string:
        return "String: " + v
    case int:
        return fmt.Sprintf("Integer: %d", v)
    case bool:
        return fmt.Sprintf("Boolean: %t", v)
    default:
        return "Unknown type"
    }
}

This is far cleaner than the alternative:

func processWithAssertions(value any) string {
    if v, ok := value.(string); ok {
        return "String: " + v
    }
    if v, ok := value.(int); ok {
        return fmt.Sprintf("Integer: %d", v)
    }
    // ... and so on
}

Type switches shine when you’re dealing with a known set of types and need different handling logic for each. They’re common in serialization code, plugin systems, and anywhere you’re processing heterogeneous data.

Type Switch Mechanics

The syntax of a type switch looks deceptively similar to a regular switch statement, but there’s important magic happening. The key is the .(type) construct, which is only valid within a switch statement:

func describe(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Printf("Integer %d\n", v) // v is int here
    case string:
        fmt.Printf("String %s\n", v) // v is string here
    case []byte:
        fmt.Printf("Byte slice with length %d\n", len(v)) // v is []byte here
    default:
        fmt.Printf("Unknown type: %T\n", v) // v is interface{} here
    }
}

The variable v is special. In each case clause, it takes on the concrete type being matched. This is different from a regular switch where the variable maintains its original type throughout. The compiler essentially creates a new variable with the appropriate type for each case block.

The scoping is important: the variable v is scoped to the entire switch statement, but its type changes per case. In the default case, v retains the original interface type.

You can also use a type switch without assigning to a variable:

func checkType(i interface{}) string {
    switch i.(type) {
    case int:
        return "integer"
    case string:
        return "string"
    default:
        return "other"
    }
}

This is useful when you only care about the type, not the value itself.

Practical Use Cases

Type switches excel in real-world scenarios where you’re processing events, handling errors, or building flexible APIs. Here’s a practical example of an event processing system:

type Event interface {
    Timestamp() time.Time
}

type UserLoginEvent struct {
    Time   time.Time
    UserID string
    IP     string
}

func (e UserLoginEvent) Timestamp() time.Time { return e.Time }

type PaymentEvent struct {
    Time   time.Time
    Amount float64
    UserID string
}

func (e PaymentEvent) Timestamp() time.Time { return e.Time }

type SystemErrorEvent struct {
    Time    time.Time
    Message string
    Stack   string
}

func (e SystemErrorEvent) Timestamp() time.Time { return e.Time }

func handleEvent(event Event) error {
    switch e := event.(type) {
    case UserLoginEvent:
        // Update user session, log access
        log.Printf("User %s logged in from %s", e.UserID, e.IP)
        return updateUserSession(e.UserID, e.IP)
        
    case PaymentEvent:
        // Process payment, update analytics
        log.Printf("Payment of $%.2f from user %s", e.Amount, e.UserID)
        return processPayment(e.UserID, e.Amount)
        
    case SystemErrorEvent:
        // Alert ops team, log to error tracking
        log.Printf("System error: %s", e.Message)
        return alertOpsTeam(e.Message, e.Stack)
        
    default:
        return fmt.Errorf("unknown event type: %T", event)
    }
}

This pattern is common in message queue processors, webhook handlers, and plugin systems. You define a common interface (here, Event) but need type-specific logic for each concrete implementation.

Another practical use case is handling different error types:

func handleError(err error) {
    switch e := err.(type) {
    case *net.OpError:
        if e.Timeout() {
            log.Println("Network timeout, will retry")
            scheduleRetry()
        } else {
            log.Printf("Network error: %v", e)
        }
    case *json.SyntaxError:
        log.Printf("JSON syntax error at byte offset %d", e.Offset)
    case *os.PathError:
        log.Printf("File operation failed: %s on %s", e.Op, e.Path)
    default:
        log.Printf("Unexpected error: %v", err)
    }
}

Type Switch Patterns and Best Practices

You can group multiple types in a single case when they need identical handling:

func processNumeric(value any) float64 {
    switch v := value.(type) {
    case int, int32, int64:
        return float64(v.(int)) // Need type assertion here
    case float32:
        return float64(v)
    case float64:
        return v
    default:
        return 0
    }
}

However, note the limitation: when you group types, the variable doesn’t take on a specific type—you still need type assertions inside the case block. This is often a code smell suggesting you should separate the cases.

A better approach for numeric types:

func processNumeric(value any) float64 {
    switch v := value.(type) {
    case int:
        return float64(v)
    case int32:
        return float64(v)
    case int64:
        return float64(v)
    case float32:
        return float64(v)
    case float64:
        return v
    default:
        return 0
    }
}

Always include a default case unless you’re absolutely certain you’ve covered all possible types. The default case is your safety net:

func mustProcess(value any) string {
    switch v := value.(type) {
    case string:
        return v
    case fmt.Stringer:
        return v.String()
    default:
        panic(fmt.Sprintf("unsupported type: %T", value))
    }
}

Handle nil explicitly when it’s a valid input:

func safeProcess(value any) string {
    switch v := value.(type) {
    case nil:
        return "nil value"
    case string:
        return v
    default:
        return fmt.Sprintf("%v", v)
    }
}

Performance Considerations

Type switches have runtime overhead—the Go runtime must check the concrete type at each invocation. For most applications, this cost is negligible. However, when performance is critical and you’re dispatching millions of times per second, interface method calls are typically faster:

// Type switch approach
func processWithSwitch(items []any) int {
    sum := 0
    for _, item := range items {
        switch v := item.(type) {
        case int:
            sum += v
        case string:
            sum += len(v)
        }
    }
    return sum
}

// Interface method approach
type Countable interface {
    Count() int
}

func processWithInterface(items []Countable) int {
    sum := 0
    for _, item := range items {
        sum += item.Count()
    }
    return sum
}

The interface method approach is faster because the method dispatch is resolved through the interface’s method table—a simple pointer lookup. Type switches require runtime type comparison.

However, type switches offer more flexibility. You can handle types you don’t control, and you don’t need to modify existing types to implement an interface. Choose based on your needs: use interface methods for extensibility and performance; use type switches for flexibility and handling external types.

Common Pitfalls and Solutions

One common mistake is forgetting that type switches only work on interface types:

// This won't compile
func broken(s string) {
    switch s.(type) { // ERROR: invalid type switch
    case string:
        fmt.Println("string")
    }
}

The variable must be an interface type. If you have a concrete type, you already know its type at compile time.

Another pitfall is scope confusion with the switch variable:

func confusing(value any) {
    v := "outer"
    switch v := value.(type) { // Shadows outer v
    case string:
        fmt.Println(v) // This is the string from value
    }
    fmt.Println(v) // This is "outer"
}

The switch variable shadows any outer variable with the same name. Use different variable names to avoid confusion.

Go doesn’t provide exhaustiveness checking for type switches like some languages do for sum types. If you add a new type to your system, the compiler won’t warn you about unhandled cases. Document your type switches clearly and consider using code generation or linters for critical dispatch points.

Conclusion

Type switches are a pragmatic feature that acknowledges a simple truth: sometimes you need runtime type dispatch. They’re not as elegant as pure polymorphism through interfaces, but they’re more flexible and often more practical.

Use type switches when you’re handling a bounded set of types and need different logic for each. Prefer interface methods when you want extensibility—when new types should be addable without modifying existing code. And remember: if you find yourself writing massive type switches, consider whether your design might benefit from interfaces or other patterns.

The key is knowing your options. Type switches, interface methods, and even reflection each have their place. Type switches sit in a sweet spot: more flexible than pure interfaces, more performant than reflection, and more readable than chained type assertions. Master them, and you’ll write more idiomatic, maintainable Go code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.