AlgoMaster Logo

Type Switch

Last Updated: May 22, 2026

High Priority
8 min read

A type switch lets you branch on the actual type stored inside a value whose static type is any (or some other interface). It's the cleanest way to handle a value that could be one of several different types, without writing a stack of if statements full of type assertions.

The Problem: One Variable, Many Possible Types

Most Go variables have a fixed type. A string is a string, an int is an int, and the compiler keeps them straight. But sometimes you genuinely don't know the type ahead of time, and you need a way to handle each possibility. Consider an E-Commerce app where a single function formats different kinds of items into user-friendly text: a Product, a Discount, or a plain string describing an error. They share a single slot in the program, and the formatting code has to look at what's actually in that slot to decide what to print.

The slot type that allows this is any (an alias for the empty interface interface{}). any is a box that can hold a value of any type. You put something in, the box remembers what type went in, and later you can ask "what did I put in here?" The type switch is how you ask that question.

The variable item is declared as any, so it can hold a string one moment and a number the next. The compiler is fine with that. What the compiler can't do is let you call len(item) or item + 1, because at compile time it has no idea what's inside. That's where the type switch comes in. For this lesson, treat any as a box and focus on how to open it safely.

Type Switch Syntax

The syntax looks like a regular switch with one extra piece: .(type) after the value being inspected.

A few details:

  • x must be an interface value. any is the most common case, but it can be any interface type.
  • .(type) is special syntax that only works inside a switch header. You can't use it anywhere else.
  • The bound variable v (you can name it anything) takes on the specific case type inside each case body. Inside case T1:, v is a T1. Inside case T2:, v is a T2.
  • default runs when none of the listed types match.

Here's a real example. A function that prints a friendly description of whatever it's given:

Look at the string case. Inside it, v is treated as a string, so len(v) and %q formatting both work. Inside the int case, v is an int, and the %d verb fits. The compiler statically knows the type inside each case, so all the usual type-specific operations are available without further casting.

The default case is the catch-all. Inside default, v keeps the original interface type (any in this example), so you can't do type-specific operations on it. You can still print it with %v and read its concrete type with %T, which is often enough for logging or error reporting.

Why a Type Switch Beats a Stack of if Statements

Without a type switch, you might chain single type assertions like this:

This works, but every new type means another three-line block. The control flow gets harder to follow, and any shared logic (a default case, logging) has to be duplicated or wrapped.

A type switch flattens all of that into a single statement:

Same output, half the visual noise. As soon as you have three or more types to dispatch on, a type switch is the idiomatic choice.

A single type assertion like s, ok := item.(string) is still useful when you only care about one specific type. Use the assertion for one type, use a type switch for several.

E-Commerce Example: A Mixed Inbox of Items

Here's a more realistic use case. An event inbox in an E-Commerce app receives different kinds of things: incoming products to list, discount events to apply, and plain error strings sent from upstream services. They all flow through a single channel-like slice of any values, and one printing routine has to format each one for an operator.

Inside each case, v is the concrete type, so v.Name, v.Code, and v.Percent all work without any extra unwrapping. The default case caught the stray float64 and surfaced it with %T, which is exactly what you want during development.

This pattern shows up often in Go code. JSON decoding into map[string]any produces values whose concrete types you discover at runtime, and event-handling code benefits from a single dispatch point that handles each known type and gracefully reports the rest.

How Dispatch Works

It helps to picture what the runtime is doing. The interface value carries two pieces of information: a pointer to the concrete value and a type descriptor that says what type that value is. The type switch reads the type descriptor and jumps to the matching case.

The diagram shows the flow for an any value that happens to hold a Product. The type descriptor identifies the concrete type, the switch matches it against each case, and execution lands in the matching block where v is typed appropriately. If nothing matches, control falls into default, where v is still just any.

Two consequences follow. First, order of cases doesn't change which one matches; each case is a separate type, and at most one matches. Second, a type switch case is exact. case int: matches values whose concrete type is exactly int, not int32 or int64. Go's strict typing carries through into the switch.

Multi-Type Cases: case T1, T2:

You can list more than one type in a single case, separated by commas. This is handy when several types should be handled the same way.

There's an important rule about the bound variable v in a multi-type case: inside such a case, v keeps the original interface type, not the matched concrete type.

This is because the case might match int or int32 or int64, and the compiler can't know at compile time which one fired. It can't give v a single fixed type, so it leaves it as any (or whatever the switch is over). You can still print it, pass it around, and feed it into another type switch, but you can't call type-specific operations on it directly.

Here's that constraint in action:

The %T verb shows the underlying concrete type, which the runtime always knows. But the static type of v inside the multi-type case is any, so any operation that requires knowing the exact type at compile time is rejected.

If you need to operate on the value, write a single-type case for each type, or nest a type assertion inside the multi-type case:

Each case sees v with its exact type and can multiply it directly. The repetition is unavoidable without generics.

The default Case

default runs when none of the listed types match. It's optional, but worth including in most real code. Without it, an unexpected type silently does nothing, which is usually a bug.

true (a bool) lands in default because no other case matches it, and %T reports bool. nil also reaches default, with %T reporting <nil>.

Inside default, v has the original interface type. You can't multiply it or index into it, but you can log it, return an error, or pass it onward. A common pattern is to log the unexpected type and return a wrapped error.

Handling nil Explicitly

An interface value can hold nil. When that happens, you can match it with case nil::

The case nil: matches only when the interface value itself is nil. If you don't include it and a nil flows in, control falls through to default (or, if there's no default, the switch does nothing). For functions that genuinely need to react differently to nil, an explicit case nil: is clearer than relying on default.

A subtle wrinkle: an interface value containing a typed nil (like a *Product that happens to be nil) is not the same as an untyped nil interface. The first matches case *Product:, the second matches case nil:.

Type Switches With Custom Types and Interfaces

A type switch isn't limited to built-in types. You can match struct types you defined yourself, and you can even match on interface types. Matching on a struct type is the most common case in real Go code.

Each case gives e the right struct type, so the field accesses (e.Name, e.Code, e.OrderID) work directly. The default case keeps you safe when an unexpected type sneaks into the pipeline.

You can also match on pointer types, like case *Product:. That matches values whose concrete type is *Product rather than Product.

Empty Type Switch vs Expression Switch

Just to keep the boundary clean: the type switch and the expression switch are two different statements that happen to share the switch keyword.

FeatureExpression SwitchType Switch
Headerswitch x { or switch x := f(); x {switch v := x.(type) {
CasesMatch values: case "shipped":Match types: case Product:
OperandAny comparable typeMust be an interface value
Bound variable type in caseSame as the switch expressionThe matched case type
FallthroughAllowed with fallthrough keywordNot allowed

If you write switch x.(type) { without binding to a variable, the cases still work but you can't access the typed value inside each case. The common form is switch v := x.(type) {. Here's the no-bind form for completeness:

This form is fine when you only need to know which category the value falls into. Most production code binds the variable so the case bodies can use it.

Putting It Together: A Small Event Processor

Here's a slightly larger example that pulls together everything in the lesson: a function that processes a stream of mixed E-Commerce events, dispatches each one to the right handler, and keeps a running summary. It uses an explicit nil case, a multi-type case, several single-type cases, and a default.

The int, int64 multi-type case prints with %v because inside that case, v has the interface type rather than a specific numeric type. The structure of the function is the real point: one dispatch site, one case per shape of event, one default for anything unexpected. Adding a new event type means adding one new case, not rewiring the function.