Right, let’s get our hands dirty with the two places you’ll most often reach for type assertions and switches outside of your own code: pulling useful information out of errors and figuring out what you’re really talking to over a network.

The standard library, in its infinite wisdom, gives us the error interface. It’s beautifully simple: one method, Error() string. This is also its biggest flaw. When something goes wrong, you get a string. Just a string. It’s like a car mechanic handing you a note that just says “broken.” Thanks. Helpful.

But what if the error was generated by os.PathError? That error contains the operation that failed, the path it was trying to access, and the underlying OS error. That’s gold! But if you just log the string, you lose all that structured data. This is where the type assertion comes in to save the day.

Unwrapping the Russian Doll of Errors

You check an error, and it’s not nil. Great. Now what? Instead of just printing it, let’s ask it, “Hey, are you by any chance an *os.PathError?” If it is, we can now access all its fields.

if err != nil {
    if pathErr, ok := err.(*os.PathError); ok {
        fmt.Printf("Operation '%s' failed on path '%s': %v\n",
            pathErr.Op, pathErr.Path, pathErr.Err)
        // Maybe you can handle this specific type of error now?
    } else {
        // It was some other type of error we don't know about
        fmt.Println("Unknown error:", err)
    }
}

This is a type assertion in its classic form. We’re asserting that the err held in the interface value is of the concrete type *os.PathError. If we’re right, ok is true and pathErr is our typed value. If we’re wrong, we don’t crash; we just get false and move on.

But modern Go (1.13+) has doubled down on this pattern with explicit error wrapping using fmt.Errorf and the %w verb. This creates a chain of errors. The standard library provides errors.As to walk this chain for us, which is almost always what you should use instead of a one-off assertion. It’s more robust.

var pathErr *os.PathError
if errors.As(err, &pathErr) {
    // Same useful handling, but now it works even if `err` merely *wraps* a PathError
    fmt.Printf("Operation '%s' failed on path '%s': %v\n",
        pathErr.Op, pathErr.Path, pathErr.Err)
}

The magic here is that errors.As traverses the entire chain of wrapped errors, checking each one to see if it can be assigned to the target type (*os.PathError). It does the iterative work you’d otherwise have to write yourself. Use it.

Figuring Out Who’s on the Other End of the Wire

Now, let’s talk protocol detection. Imagine you’re writing a server that accepts a network connection. The client might speak one of two protocols: a simple text-based protocol or a more complex binary one. They both start by sending a little something to identify themselves.

You read the first few bytes. Now what? You have an io.Reader and a slice of bytes. This is a perfect job for a type switch, but not on the data itself—on a constructed interface.

The trick is to create multiple objects that can “peek” at the data and tell you if it’s theirs. Each protocol checker implements the same method, say Detect([]byte) bool.

type Detector interface {
    Detect([]byte) bool
}

type TextProtocol struct{}
type BinaryProtocol struct{}

func (TextProtocol) Detect(b []byte) bool  { return string(b[:4]) == "TEXT" }
func (BinaryProtocol) Detect(b []byte) bool { return b[0] == 0xAB && b[1] == 0xCD }

Now, the magic happens. You have your peek data. You make a slice of the possible detectors and loop through them, using a type switch inside the loop to handle the connection differently for each specific type.

func handleConnection(conn net.Conn) {
    peekBytes := make([]byte, 4)
    conn.Read(peekBytes)

    detectors := []Detector{TextProtocol{}, BinaryProtocol{}, DefaultProtocol{}}

    for _, detector := range detectors {
        if detector.Detect(peekBytes) {
            // This is the crucial line. We switch on the type of the detector.
            switch d := detector.(type) {
            case TextProtocol:
                fmt.Println("Handling text protocol")
                handleText(conn, peekBytes) // You'd pass the conn and peeked bytes
            case BinaryProtocol:
                fmt.Println("Handling binary protocol")
                handleBinary(conn, peekBytes)
            case DefaultProtocol:
                fmt.Println("Using default protocol")
                handleDefault(conn)
            }
            return // break out after the first match
        }
    }
}

Why is this so powerful? The switch d := detector.(type) line lets you safely branch your logic based on the concrete type of the detector that matched. The compiler knows that inside the case TextProtocol: branch, d is of type TextProtocol. This pattern keeps your detection logic neatly decoupled from your handling logic. It’s extensible—adding a new protocol is just creating a new struct and adding it to the detectors slice.

The pitfall to avoid here is forgetting that the type switch cases must be concrete types (or interface types, but that’s a more advanced trick). You can’t write a case for TextProtocol if you’ve stored *TextProtocol pointers in your slice. The concrete type in the slice is what matters. This is one of those places where Go’s simplicity forces you to be very deliberate about your types, and it’s ultimately for the best. You’ll thank yourself later when your code isn’t a tangled mess of ambiguous pointers.