13.3 Type Switches: switch v := x.(type)
Alright, let’s get our hands dirty with type switches. You’ve met type assertions, that wonderfully confident (and sometimes arrogant) way of telling the compiler, “I know what this interface{} really is. Trust me.” A type switch is its more cautious, systematic cousin. It’s the control structure built specifically for figuring out what’s hiding inside an interface value. It’s how you safely interrogate an interface{} without getting a runtime panic for your trouble.
The syntax looks a bit funky at first, but you’ll get used to it. Here’s the basic shape of the beast:
switch v := x.(type) {
case T1:
// here v is of type T1
case T2:
// here v is of type T2
default:
// here v is... still the interface{} we started with
}
The magic is in the .(type) bit. It’s a keyword reserved strictly for use within a type switch. You can’t use it anywhere else, and if you try, the compiler will get rather snippy with you.
The Two Flavors of Type Switch
You can write a type switch in two ways, and the difference is subtle but important. The first way, which I strongly recommend, declares a new variable v.
func describe(x interface{}) {
switch v := x.(type) {
case string:
fmt.Printf("It's a string! Value: %s\n", v) // v is a string
case int:
fmt.Printf("It's an int! Value: %d\n", v) // v is an int
default:
fmt.Printf("I have no idea what this is: %v\n", v) // v is still interface{}
}
}
In each case branch, v is automatically redeclared with the type of that case. This is brilliant because it gives you a properly typed variable to work with right there in the branch. No more messy assertions.
The second way omits that variable declaration, and you just use the original variable x. This is, frankly, a bit daft most of the time because you’re still stuck with an interface{} and you’ll just have to assert it again inside the case. It’s more typing and more error-prone. Avoid it.
// Don't do this unless you have a *very* good reason.
switch x.(type) {
case string:
str := x.(string) // Ugly, redundant assertion
fmt.Println(str)
// ...
}
The Peculiar Case of nil
Here’s a classic head-scratcher. What happens when the interface value itself is nil?
var x interface{} // x is nil
x = nil // explicitly setting it to nil
switch v := x.(type) {
case nil:
fmt.Println("x is nil! This case will match.")
default:
fmt.Printf("Unexpected type: %T\n", v)
}
The type switch has a special case nil that handles this. It’s crucial to remember that an interface{} can be nil and it can hold a value of a concrete type that is nil. It’s a distinction that has caused more than one developer to question their life choices.
var *int p = nil
x = p // x now holds a value of type *int, and that value is nil
switch v := x.(type) {
case nil:
fmt.Println("This will NOT print.")
case *int:
fmt.Printf("This will print. v is of type *int and its value is %v\n", v) // v is nil, but its type is *int
}
Fallthrough is a Lie
This is one of my favorite quirks. The fallthrough statement is forbidden in type switches. Think about it: it would be utter nonsense. fallthrough would try to jump into a code block that expects a completely different type. The Go designers, in a rare moment of obvious clarity, simply made it a compile-time error. Thank goodness.
Matching Multiple Types
You can list multiple types in a single case, separated by commas. This is incredibly useful for grouping types that share behavior or that you want to handle identically.
func isNumber(x interface{}) bool {
switch x.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, complex64, complex128:
return true
default:
return false
}
}
Just remember, inside that case clause, your variable v (if you declared one) will be of the type of the interface value, not some unified “number” type. So you can’t do arithmetic on v directly here; you’d need another type switch or a conversion inside the case. It’s primarily for categorization.
The Default Case and Exhaustiveness
The default case is your catch-all for anything you didn’t explicitly list. But here’s the pro-tip: don’t reach for it too quickly. Often, you know all the possible types a value could have (e.g., you’re dealing with a set of structs from your own package). In those cases, omit the default case entirely.
Why? Because it future-proofs your code. If you add a new type tomorrow and forget to handle it in this switch, the compiler won’t complain if you have a default. The switch will just silently dump the new type into the default clause, likely causing subtle bugs. If you omit default, the switch will do nothing for the new type, which is often safer. Even better, tools like golangci-lint can warn you about non-exhaustive type switches, but only if there’s no default case to make the linter think you’ve got it covered.
So use default when you genuinely are dealing with a truly unknown set of types (like in a general-purpose library function). Otherwise, let your switch be explicit and let the compiler help you keep it that way.