Right, so you’ve got a value sitting in an empty interface (interface{} or any), and you need to figure out what it actually is. A simple type assertion (val.(int)) is great when you’re expecting one specific type. But life, and especially code that deals with user input, network requests, or vaguely defined external libraries, is rarely that neat. You often need to handle several possible types. This is where the type switch shines. It’s essentially a powerful if/else if chain on steroids, letting you branch your logic based on the concrete type hiding inside that interface.

Think of it like a sorting machine. A single assertion is a chute for “red balls only.” A type switch is the full machine with chutes for red balls, blue cubes, and a bin for “everything else I didn’t anticipate.” It lets you handle each case appropriately without a tangled mess of if statements and ok variables.

Here’s the basic syntax. Notice it looks suspiciously like a regular switch statement, but the conditional is a type assertion.

func describe(i interface{}) string {
    switch v := i.(type) {
    case int:
        return fmt.Sprintf("This is an int: %d. I can do math with it: %d", v, v*2)
    case string:
        return fmt.Sprintf("This is a string: %s. Its length is %d", v, len(v))
    default:
        return fmt.Sprintf("I have no idea what this is: %#v (type %T)", v, v)
    }
}

The v := i.(type) Trick

This is the magic. The v := i.(type) clause does two things for you in each case branch:

  1. It asserts the type: It checks if i matches the type in the case.
  2. It gives you a typed variable: The variable v is automatically the type of that specific case. Inside the case int: block, v is an int; inside case string:, it’s a string. This saves you from the clunky “comma ok” idiom inside every single branch. It’s brilliantly concise.

The Non-Obvious Pitfall: fallthrough

This is a classic Go “gotcha.” In a regular switch statement, fallthrough pushes execution into the next case’s code block. In a type switch, this is almost always a terrible idea. The next case is a completely different type!

func badExample(i interface{}) {
    switch v := i.(type) {
    case int:
        fmt.Println("It's an int:", v)
        fallthrough // NO! DON'T DO THIS!
    case string:
        fmt.Println("It's a string:", v) // v is still an int here! This will panic.
    }
}

The compiler can’t save you here. Using fallthrough in a type switch is a one-way ticket to a runtime panic and a very confused future developer (probably you, at 2 AM). Just don’t do it.

Handling Multiple Types in a Single Case

Sometimes, you want to handle several disparate types the exact same way. Maybe you want to treat both int and float64 as “numbers” for a particular operation. The type switch lets you combine cases logically.

func isNumber(i interface{}) bool {
    switch i.(type) {
    case int, int8, int16, int32, int64,
         uint, uint8, uint16, uint32, uint64, uintptr,
         float32, float64,
         complex64, complex128:
        return true
    default:
        return false
    }
}

This is clean, readable, and far superior to a giant if statement with repeated logic. It’s a set-based approach: “If the type is in this set, do this.”

Matching Underlying Types with Custom Types

This trips people up. A type switch matches on the exact type, not the underlying type. If you have a custom type type MyString string, a case string: will not catch a value of type MyString.

type MyString string

func checkString(i interface{}) {
    switch v := i.(type) {
    case string:
        fmt.Println("It's a plain string:", v)
    case MyString: // You need an explicit case for your custom type.
        fmt.Println("It's a MyString:", string(v)) // You might need to convert it.
    default:
        fmt.Println("Not a string at all.")
    }
}

This makes sense when you think about it. The whole point of creating a new type is to have a distinct type, even if its underlying representation is the same. The type switch respects that distinction.

The Crucial default Case

Never, ever forget your default case. It’s your safety net for all the types you didn’t anticipate. It’s where you handle the “this should never happen” scenarios that, of course, eventually happen. Without it, your code will just silently do nothing when it encounters an unexpected type, and you’ll be left debugging a non-obvious null value or a missing log line. Make your default case noisy—log an error, return a clear error message, or panic if it’s truly unrecoverable. Silent failures are the worst kind of failure.

func safeExample(i interface{}) (int, error) {
    switch v := i.(type) {
    case int:
        return v, nil
    case float64:
        return int(v), nil // Handle a different number type, with a clear conversion.
    default:
        return 0, fmt.Errorf("unsupported type: expected int or float64, got %T", i)
    }
}

The type switch is your best friend when dealing with unknown types. It turns a potentially messy problem into a structured, readable, and safe piece of logic. Use it wisely, avoid fallthrough like the plague, and always, always remember your default.