The router for interface values
You're building a configuration loader. The input comes from a JSON file, a YAML blob, or a command-line flag. By the time the data reaches your processing function, it's wrapped in an any value. You need to extract the value, but you don't know if it's a string, an integer, or a boolean until the program runs. A standard switch statement compares values. A type switch compares types. It inspects the dynamic type hidden inside the interface and routes execution to the matching branch.
Think of a type switch like a sorting facility for packages. Every package arrives with a label, but the label might be smudged or generic. The sorting machine scans the package. If the scan reveals a "Fragile" tag, the machine routes it to the careful handling lane. If it's "Heavy", it goes to the freight lane. If it's "Standard", it takes the conveyor belt. The type switch performs that scan on the interface value, identifies the concrete type, and directs the code to the right lane.
Type switches turn runtime uncertainty into structured control flow.
Minimal example
Here's the simplest type switch: define the variable, match cases, use the typed variable inside.
package main
import "fmt"
// Describe prints details based on the dynamic type of the input.
func Describe(val any) {
// The type switch syntax assigns the value to a variable
// with the concrete type of the matched case.
switch v := val.(type) {
case int:
// v is an int here. Arithmetic works directly.
fmt.Printf("Integer: %d\n", v)
case string:
// v is a string here. String methods work directly.
fmt.Printf("String: %q\n", v)
default:
// v is the original interface value here.
// Use %T to print the type name for debugging.
fmt.Printf("Unknown type: %T\n", v)
}
}
func main() {
Describe(42)
Describe("hello")
Describe(true)
}
The variable v exists only inside the switch. Scope is your friend.
How the syntax works
The type switch syntax has two forms. The first form, shown above, includes a type assertion assignment: switch v := val.(type). This is the most common pattern. It declares v and gives it the concrete type of the matched case. Inside the case int: block, v is an int. Inside case string:, v is a string. The compiler enforces this typing strictly. You can call methods or perform operations valid for that type without further casting.
The second form omits the assignment: switch val.(type). Use this when you only need to branch on the type and don't need the value itself. This is rare but useful for validation or logging where you just want to know what arrived.
The syntax val.(type) is unique. You cannot use it outside a type switch. If you write if val.(type) == int, the compiler rejects the program with invalid type switch expression val.(type). The language designers locked this syntax to the switch statement to force structured branching. Type assertions like val.(int) exist for single checks, but type switches are the tool for multi-way dispatch.
Go 1.18 introduced any as an alias for interface{}. Use any in signatures for readability. The compiler treats them identically. The community convention is to prefer any unless you are interacting with legacy code that requires the explicit interface{} spelling.
The compiler enforces the syntax. .(type) lives inside switch or nowhere.
Realistic example
Here's a realistic processor: iterate a mixed slice, extract numbers for a sum, collect strings, and skip nils.
package main
import "fmt"
// SumAndJoin processes a mixed slice, summing numbers and joining strings.
func SumAndJoin(items []any) (int, string) {
var total int
var parts []string
for _, item := range items {
switch v := item.(type) {
case int:
// Accumulate integer values directly.
total += v
case float64:
// Convert floats to int for the sum.
// Truncation happens here; handle precision needs explicitly.
total += int(v)
case string:
// Collect strings for joining.
parts = append(parts, v)
case nil:
// Skip nil values explicitly.
// Nil is a valid type in type switches.
continue
}
}
return total, fmt.Sprint(parts)
}
func main() {
data := []any{1, "a", nil, 2.5, "b", "c"}
sum, joined := SumAndJoin(data)
fmt.Printf("Sum: %d, Joined: %s\n", sum, joined)
}
Nil is a valid case. Handle it explicitly or risk silent skips.
Pitfalls and traps
Type switches have a few traps that catch developers who assume the variable type persists across multiple cases or who forget how nil behaves.
Multiple types strip the concrete type
When you list multiple types in a single case, the variable inside that block reverts to the interface type. This is the most common mistake.
switch v := val.(type) {
case int, float64:
// v is any here, not int or float64.
// The compiler cannot guarantee which type v holds.
fmt.Println(v)
}
If you try to perform arithmetic on v in this block, you get invalid operation: v * 2 (mismatched types any and untyped int). The compiler sees v as any because the case matches either int or float64, and there is no single concrete type that covers both. Split the case into separate branches to keep the variable typed.
Multiple types in a case strip the concrete type. Split the case to keep the variable typed.
Nil handling
An interface value can hold nil. If you pass nil to a type switch and don't include a case nil:, the default branch catches it. If you omit default, the switch simply does nothing. This silent skip is a common source of bugs. Explicitly handle nil when the input might be empty.
Type assertion panic vs type switch safety
A type assertion val.(int) panics if val does not hold an int. A type switch never panics. It falls through to the next case or the default. This makes type switches safer for exploring unknown values. If you need the panic behavior, use a type assertion. If you need safe branching, use a type switch.
Error types and wrapping
When working with errors, prefer errors.As over type switches. Type switches match the concrete type, but errors.As unwraps error chains to find the target type. If an error is wrapped by fmt.Errorf, a type switch on the concrete error type will fail to match. errors.As respects the error wrapping convention and traverses the chain.
var targetErr *MyError
if errors.As(err, &targetErr) {
// Handle MyError, even if wrapped.
}
For error types, prefer errors.As over type switches to handle wrapped errors correctly.
Decision matrix
Use a type switch when you need to branch logic based on the concrete type of an interface value and handle multiple distinct types in one block. Use a type assertion with the comma-ok idiom when you only care about one specific type and want to check for its presence without branching on others. Use generics when the type is known at compile time and you want to write type-safe functions that work across a set of types without runtime overhead. Use errors.As when checking for specific error types, especially when errors might be wrapped by other layers. Use the reflect package when you need to inspect types dynamically at runtime for tools like serializers or debuggers, not for application logic.
Generics eliminate the need for type switches when types are known. Prefer compile-time safety over runtime branching.