AlgoMaster Logo

Type Switches

Last Updated: May 22, 2026

High Priority
11 min read

Type switches let you branch on the concrete type stored inside an interface value. We already saw the basic syntax in the Type Switch lesson under Control Flow. This lesson picks the topic back up in its real home, interfaces, and digs into the patterns that show up in production code: dispatching events, processing heterogeneous payloads, handling related types together, and recognizing when a type switch is the wrong tool.

Recap of the Syntax

The form is a regular switch with .(type) after the value being inspected.

Three things to lock in before we go further.

  • The value on the left of .(type) must be an interface value. any is the common case, but it can be any interface type. .(type) only works inside a switch header.
  • The bound variable, here v, takes on the case's type inside each single-type case body. Inside case string:, v is a string, so len(v) works without any extra step.
  • In a multi-type case like case int, int64:, the bound variable keeps the original interface type, not one of the listed types. That trips up everyone the first time.

When to Use a Type Switch

Type switches earn their place when one piece of code has to react to several concrete types that arrive through the same interface slot. The clearest signal is "I have a value of some interface type and I need to do different things based on what's actually inside it."

E-Commerce code is full of these moments. An event handler reads a queue of mixed events and dispatches each one. A notification service formats messages differently depending on whether the payload is an SMS, email, or push notification record. A reporting job iterates a slice of order entries where each entry is one of several event structs.

Here's a stripped-down event handler. The handler receives Event values from a single channel, and each event carries different fields and needs a different action.

Each case lands in a body where e already has the right struct type, so the field accesses are direct. Adding a new event type means adding one new case, not threading a new branch through nested if blocks. This is exactly the shape of code type switches were designed for.

Each case runs a type-descriptor comparison, which is cheap (roughly a pointer compare). A switch over a handful of cases is essentially free. A switch over hundreds of cases is still O(n) in the number of cases, so if you find yourself dispatching across dozens of types, a map[reflect.Type]handler or a tag-keyed map is faster.

Type Switch vs Chained Type Assertions

A common path before learning type switches is to chain single type assertions with if v, ok := x.(T); ok. That works, but it scales badly.

It works, but every new event type means another four-line block, another return, another small chance to mistype a case. The same code as a type switch:

Same behavior, less noise. The rule of thumb most Go codebases settle on: one type, use a single assertion; three or more, use a switch. Two is a judgment call, and a switch usually still reads better because it groups the related cases visually.

A single assertion still has a legitimate role when you only care whether a value implements one specific type. For example, asking "is this error also a net.Error?" is a single assertion, not a switch.

The Bound Variable's Type Inside Each Case

The mechanic that makes type switches feel natural is how the bound variable changes type inside each case body. Note this part, because it's the difference between a switch that reads cleanly and one that fights you.

len(v) works inside the string case because v really is a string there. v.Code works in the Discount case because v really is a Discount. v*2 works in the int case because v really is an int. The compiler statically narrows the type inside each branch, so you write the code you'd write if you already knew the type.

That narrowing only happens for single-type cases. Multi-type cases are the next thing to watch out for.

Multi-Type Cases and the Type Trap

You can group types in a single case, separated by commas. Useful when several types should be handled the same way.

So far so good. The trap is that inside a multi-type case, the bound variable keeps the original interface type, not one of the listed types. Here v stays any inside both number cases. That means any operation requiring a specific type at compile time, like arithmetic or struct field access, won't compile.

The compiler can't know which of int or int64 actually matched, so it can't pick a single type for v. Two ways out, both common:

Split into single-type cases:

Each case now has a concrete type for v, so the multiplication compiles in both branches. The cost is repeating the body. With generics added in Go 1.18, you can sometimes factor that out into a generic helper, but for two or three integer widths the duplication is fine.

Nest a type assertion inside a multi-type case:

This is uglier than splitting cases, and it usually is the wrong answer. The reason to know it exists: when you have ten numeric types all printing the same way, the single-line multi-type case with %v formatting is the cleanest option, because you don't need arithmetic.

Matching on Interface Types Inside a Switch

A type switch isn't limited to concrete types. A case can list an interface, and the case matches whenever the value satisfies that interface. This is one of the most useful tricks for handling related types together.

Here's a realistic E-Commerce example. A Notifier interface defines anything that can send a notification, and several concrete types satisfy it. A logging function wants to handle notifier values with a single branch, but also wants special handling for one specific concrete type.

First, the EmailNotifier case comes before the Notifier case, and the order matters. A type switch evaluates cases top to bottom, and the first match wins. If case Notifier: came first, every EmailNotifier would match it and the special-case branch would never run. Second, inside the Notifier case, v has type Notifier, not the underlying concrete type. You can call v.Send() because that's a method on the interface, but you can't access concrete fields like v.To.

This pattern, "match a specific concrete type, then fall through to the interface for everything else", is one of the genuinely good uses of a type switch over an interface. It's how the standard library handles things like error chains, where errors.As walks a chain looking for a specific concrete error type before treating the rest as plain errors.

Ordering When Cases Are Related

The ordering rule kicks in any time cases overlap. Concrete types never overlap with each other (a value's concrete type is exactly one type), but a concrete type can overlap with an interface, and one interface can overlap with another more specific interface.

The diagram captures the rule of thumb: most-specific case first, broadest case last. A concrete type case beats an interface case, and a narrower interface beats a wider one. Writing the cases in that order means the most specific match always wins. Writing them in the reverse order means the broad interface case absorbs everything and the specific cases never fire.

Here's the same idea applied to the Notifier example with two interfaces of different widths.

EmailNotifier satisfies both RetryableNotifier and Notifier. The first case matches because it appears first. SMSNotifier only satisfies Notifier, so it falls through to the second case. Reverse those two cases and every EmailNotifier would lose its retry handling. The compiler can't catch that mistake because both orderings are valid programs, just with different behavior.

The nil Case

An interface value can hold the untyped nil, and a type switch can match that with case nil:.

Two subtleties worth flagging.

First, omitting case nil: doesn't make nil illegal. It just routes to default, or, if there's no default, to nothing. Including the explicit case is the clearest way to signal that nil is a known input.

Second, a typed nil and an untyped nil are not the same value. A *OrderPlaced that's nil, wrapped in an any, is a non-nil interface value whose concrete type is *OrderPlaced and whose underlying pointer is nil. It matches case *OrderPlaced:, not case nil:. Inside that case, the bound variable is a nil *OrderPlaced, so dereferencing it panics. The Interfaces Basics lesson covers why this is and how to defend against it.

The interface value wraps a typed nil, so it matches case *OrderPlaced:. Inside that case, e is *OrderPlaced, equal to nil. Any field access through e would panic. The fix is to test for nil after the case matches: if e == nil { return }.

Processing Heterogeneous JSON

A frequent real-world driver for type switches is JSON decoding. When you decode into map[string]any or []any, the standard library gives you back values whose concrete types depend on the JSON shape: strings become string, numbers become float64, booleans become bool, arrays become []any, and objects become map[string]any. A type switch is how you walk that tree.

The function recurses into nested arrays and objects, hitting the type switch once per node. Every type that JSON can produce gets its own case, and the function stays compact. Note that JSON numbers decode to float64 by default, even for integers like 1042. That's a standard-library quirk, not a type-switch quirk, but it's the kind of thing the default case helps you discover the first time.

Output order for the map[string]any case depends on Go's map iteration order, which is randomized. The actual order of the fields you see may differ between runs.

When a Type Switch Is a Code Smell

Type switches are useful, but they can also be a warning sign that the design wants a method on an interface instead. The clearest case is when every branch of the switch is calling the same conceptual operation on slightly different types.

Here's a switch that looks fine at first:

This works, but it has a smell. Every case is doing the same thing: "format this thing as a string." That's the textbook signature for a method on an interface, not a switch. The same code with a Formatter interface:

Adding a new notification type now means writing one new struct with one new Format method. The calling code doesn't change. With the switch version, every new type means editing the switch in every place that formats notifications.

The rule isn't "never use a type switch." The rule is: when the branches of a switch are all calling "the same operation" on different shapes, define that operation as a method on an interface, and let polymorphism do the dispatch. Save the type switch for cases where the branches really do different things, like JSON walking, error introspection, and event-processing pipelines where each event type triggers a genuinely different workflow.

When you use a switch, take a second to ask: "Am I just re-implementing polymorphism by hand?" If yes, an interface method is cleaner.

A Note on Algebraic Data Types

If you're coming from a language with sealed types or algebraic data types (Rust's enum, Kotlin's sealed class, Haskell's data types, Swift's enums with associated values), the type switch over an interface is the closest Go gets to that pattern. It's not the same thing. Go has no notion of a "sealed" interface that's exhaustively listed at compile time. Any new type that satisfies the interface counts, the compiler will not warn you that your switch missed a case, and there's no compile-time exhaustiveness check.

This is a real difference, not a minor one. In Rust, adding a new variant to an enum is a compile error in every match that doesn't handle it, which makes refactoring safe. In Go, adding a new type that satisfies an interface is picked up by every type switch, falling through to default (or nothing) without complaint. The fix is convention, not compiler help: always include a default case that surfaces unexpected types, and treat the switch as documentation of "the types we currently know about" rather than "the only types that exist."

Go's design favors open extension over closed exhaustiveness. The trade-off lets new types satisfy old interfaces without changes upstream, which is a big part of what makes the language feel decoupled. It costs you compile-time pattern matching guarantees, and the default case is how you pay for it at runtime.

Putting It Together: A Notification Service

A small but realistic notification service that pulls together everything in the lesson. It uses concrete cases for special-case handling, an interface case for the general path, a default for unknown types, and explicit nil handling.

Read the structure of dispatch and you get a tour of the lesson. case nil: handles the untyped nil. case EmailNotification: handles one concrete type specially because emails have a priority field that matters for routing. case Notifier: is the limitation-all for anything else that implements the interface. default reports an unsupported payload as an error so the calling code can react. The order of the cases is the order of specificity, most specific first. Add a new notifier type and everything still works, with the new type landing in case Notifier: automatically.