JavaRush /Courses /Go SELF /Type switch in Go: choosing a branch

Type switch in Go: choosing a branch

Go SELF
Level 25, Lesson 2
Available

1. When you need a type switch

Almost every beginner goes through the same phase: “I’ll put a value into any, and then… somehow I’ll figure it out.” It’s like stuffing all your cables into one bag and hoping the internet will magically know which one is power and which one is HDMI. A type switch is your way to figure it out in a civilized manner when a value arrives as an interface (any, error, or your own interface) and then you need to pick logic based on the concrete type.

Typical situations where a type switch is genuinely appropriate: you accept a value of type any because it’s “mixed”; you received an error and want to understand whether it is a particular error type; you’re building a command/event dispatcher where different commands are represented by different structs but are stored behind an interface. By the way, Go officially treats type assertions and type switches as one of the ways to “see” the concrete error type, because error is an interface.

2. Regular switch vs type switch

From the outside they look similar, but conceptually they’re two different tools. A regular switch selects a branch by the value of an expression (for example, cmd == "add" or day == 5). A type switch selects a branch by the type of the value stored inside an interface (for example, inside any there is an int or a string).

When you write a regular switch, the compiler already knows the type of the expression. When you write a type switch, the compiler only knows “this is an interface,” and you ask: “check which concrete type is really inside, and give me a variable already typed as that type.”

For convenience, let’s lock this in with a table—it’s useful to keep in mind so you don’t try to “bolt on” a type switch where a regular one is needed:

What we compare Tool What gets selected Example mental model
Value
switch x { ... }
branch by value “if the command equals "add"…”
Type inside an interface
switch x.(type) { ... }
branch by type “if inside there is
int
…”

Syntax: switch v := x.(type) { ... }

The construct x.(type) cannot be used as a standalone expression. It exists only inside a type switch. That is, you can’t write t := x.(type)—the compiler simply won’t accept it.

Canonical form:


switch v := x.(type) {
case int:
    // v has type int
case string:
    // v has type string
default:
    // x is still available as x (interface), but v does not exist here
}

Two things happen here at the same time.

First, x must have an interface type (any, error, or your own interface).

Second, the variable v inside each branch gets the concrete type of that branch, and you can work with it without additional type assertions.

Mini example: the describe function

Imagine we have a debug helper function that prints a description of a value:

package main

import "fmt"

func describe(x any) {
	switch v := x.(type) {
	case int:
		fmt.Println("int:", v) // int: 7
	case string:
		fmt.Println("string:", v) // string: go
	default:
		fmt.Printf("unknown: %T\n", x) // unknown: bool
	}
}

Note: inside case int: the variable v is already an int, not an any. That means you can do arithmetic, comparisons, and any operations allowed for int.

3. Example: a command dispatcher

Let’s continue a learning storyline with a small console app called “TaskBox” (a made-up name) where we keep tasks in memory. We won’t build a complex architecture today; we’ll do one specific thing: learn to represent commands as different structs and handle them with a single function via a type switch.

The idea is: the command parser returns any because the command can have different shapes. For example, AddCmd, DoneCmd, ListCmd. Then the dispatcher branches by type.

First, let’s define the command types (minimal):

package main

type AddCmd struct{ Title string }
type DoneCmd struct{ ID int }
type ListCmd struct{}

Now let’s build a “command runner.” In a real project you’d more often define an interface with a Run() method, but here it’s important to demonstrate the type switch as a dispatch mechanism by concrete types:

package main

import "fmt"

func runCommand(cmd any) error {
	switch c := cmd.(type) {
	case AddCmd:
		fmt.Println("ADD:", c.Title) // ADD: Buy milk
	case DoneCmd:
		fmt.Println("DONE:", c.ID) // DONE: 3
	case ListCmd:
		fmt.Println("LIST") // LIST
	default:
		return fmt.Errorf("unknown command type %T", cmd)
	}
	return nil
}

Here’s the key win: in each branch c has a concrete type. In the AddCmd branch, c has the Title field; in the DoneCmd branch, it has the ID field. No separate cmd.(AddCmd), no ok, no chains of if.

To make it completely clear, here’s what it could look like in main:

package main

import "fmt"

func main() {
	_ = runCommand(AddCmd{Title: "Buy milk"}) // ADD: Buy milk
	_ = runCommand(DoneCmd{ID: 3})            // DONE: 3
	_ = runCommand(ListCmd{})                 // LIST
	fmt.Println("ok")                         // ok
}

Yes, this is still a “toy,” but these toys turn into real dispatchers: events, messages, different API response variants, and so on.

4. Useful nuances

Multiple types in one case

When the number of type variants grows, you’ll almost inevitably want to handle multiple types the same way.

For example, numeric types:

package main

import "fmt"

func printNumber(x any) {
	switch v := x.(type) {
	case int, int64:
		fmt.Printf("number: %v\n", v) // number: 10
	default:
		fmt.Printf("not a number: %T\n", x)
	}
}

The subtlety here is: if multiple types are listed in a case, the variable v does not get one specific type, because the compiler has to choose a common representation for that branch. In practice, v will have the type of the original type-switch expression (i.e., the interface type—here, any). So if you need type-specific operations for a particular numeric type inside the branch, you usually write separate case clauses.

A branch for “anything that implements an interface”

In a type switch you can branch not only by a concrete type, but also by an interface. This is like a “behavior check”: we don’t care about the exact type, only that the required methods exist.

Suppose we want to pretty-print anything that implements fmt.Stringer. Then we can write case fmt.Stringer:

package main

import (
	"fmt"
)

type Task struct{ Title string }

func (t Task) String() string { return "Task: " + t.Title }

func show(x any) {
	switch v := x.(type) {
	case fmt.Stringer:
		fmt.Println(v.String()) // Task: Read Go book
	default:
		fmt.Printf("%v\n", x)
	}
}

The key point is that case fmt.Stringer: will catch any types that implement that interface, not only Task.

case nil and typed nil

One of the nastiest beginner traps in Go is related to nil in interfaces, because it looks like “I did everything right, and Go is trolling me.” In reality Go isn’t trolling—it’s just literally following the “type + value” model.

An interface has two states.

First: the interface is truly empty (nil), meaning it has neither a type nor a value.

Second: the interface has a type, but the value of that type is nil (for example, a *Task with the value nil). In the second case the interface is already not nil, because “the type is present.”

Let’s look at a short example:

package main

import "fmt"

type Task struct{ Title string }

func main() {
	var x any
	fmt.Println(x == nil) // true

	var t *Task = nil
	x = t
	fmt.Println(x == nil) // false
	fmt.Printf("type=%T\n", x) // type=*main.Task
}

Now the most important part about a type switch: case nil triggers only in the first case—when the interface itself is truly nil. If there is a typed nil inside, then case nil will not trigger, because a type exists inside.

Here is what it looks like in a type switch:

package main

import "fmt"

type Task struct{ Title string }

func main() {
	var t *Task = nil
	var x any = t

	switch v := x.(type) {
	case nil:
		fmt.Println("nil interface")
	case *Task:
		fmt.Println("typed nil pointer:", v == nil) // typed nil pointer: true
	}
}

Notice the last line: in the case *Task: branch, the variable v has type *Task, and at the same time it is actually nil. So a typed nil is not a “magic fake nil”—it’s a real nil pointer; it’s just that the surrounding interface is no longer empty.

Order of case: specific first, general later

When you use both concrete types and interfaces in a type switch, branch order starts to matter. The reason is simple: if you put a “wide net” earlier, it will catch everything, and execution will never reach the “narrow” branch.

For example, Task implements fmt.Stringer. If you put case fmt.Stringer: above case Task:, the Task branch may never run for a Task value, because it already matched fmt.Stringer.

Let’s show it as briefly as possible:

package main

import (
	"fmt"
)

type Task struct{ Title string }

func (t Task) String() string { return "Task(" + t.Title + ")" }

func main() {
	var x any = Task{Title: "Learn type switch"}

	switch v := x.(type) {
	case fmt.Stringer:
		fmt.Println("stringer:", v.String()) // stringer: Task(Learn type switch)
	case Task:
		fmt.Println("task:", v.Title)
	}
}

If you truly want separate handling for Task, you just change the order: first case Task:, then case fmt.Stringer:. This is like regular condition checks: more specific first, more general later.

5. Common mistakes when working with a type switch

Mistake #1: trying to use .(type) outside a switch.
A very common situation: a student tries to write something like t := x.(type) or to put x.(type) into fmt.Printf. You can’t do that in Go syntax: .(type) exists only as part of a type switch. If you need to extract a concrete type for one case, use a type assertion: v, ok := x.(T). If you have many variants—use a type switch.

Mistake #2: running a type switch on a value that is not an interface.
A type switch works only if the expression has an interface type. That is, switch x.(type) is valid if x is any, error, or another interface. If x is an int, string, or Task, then it simply doesn’t make sense: the concrete type is already known, and the compiler won’t allow such a construct.

Mistake #3: expecting case nil to catch typed nil.
This is a logical trap: “but there’s nil inside—so case nil.” But case nil checks not the “inner value,” but the state of the interface itself. If the interface stores the type *Task, then it is no longer nil, even if the value is nil. In such cases it’s useful to print %T explicitly and remember the “type + value” model.

Mistake #4: no default and silently ignoring unknown types.
If you do a type switch over any and you don’t define default, an unexpected type will simply be ignored. In tutorial code this might “work,” but in real code it turns into mysterious bugs: the function did nothing—and it’s unclear why. Usually default is either an explicit error or a clear fallback (for example, fmt.Printf("unknown %T", x)).

Mistake #5: wrong branch order when you have both concrete types and interfaces.
If you put case fmt.Stringer: above case Task:, then Task will start being handled “as a stringer,” and your specialized logic won’t run. Then you look at the code and think, “why doesn’t the Task branch trigger?” The answer is usually banal: it’s unreachable due to a more general case placed earlier.

1
Task
Go SELF, level 25, lesson 2
Locked
Value descriptor
Value descriptor
1
Task
Go SELF, level 25, lesson 2
Locked
Guest or nil
Guest or nil
1
Task
Go SELF, level 25, lesson 2
Locked
Command dispatcher
Command dispatcher
1
Task
Go SELF, level 25, lesson 2
Locked
Price and note
Price and note
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION