Skip to main content

Type Assertions and Type Switches in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 6 min read

When a value is stored in an interface, you sometimes need to get back to its concrete type — to call a method that’s not on the interface, to check which implementation you have, or to extract error details. Type assertions and type switches are Go’s two mechanisms for this.

Both work only on interface values. You can’t assert a string to an int directly — but you can assert an interface{} (or any) containing a string to string. Understanding when each mechanism is appropriate saves you from panics and makes code handling polymorphic values clear.

For interface fundamentals see Go interfaces and Go implicit interfaces.

Type Assertions

A type assertion extracts the concrete value from an interface. The single-return form panics if the assertion fails; the two-value form returns a boolean instead:

var i any = "hello"

// Single-value form: panics if i is not a string
s := i.(string)
fmt.Println(s)  // hello

// Two-value form: safe, never panics
s, ok := i.(string)
if ok {
    fmt.Println("got string:", s)
} else {
    fmt.Println("not a string")
}

// Asserting to the wrong type
n, ok := i.(int)
fmt.Println(n, ok)  // 0 false

Always use the two-value form unless you’re certain the assertion will succeed and a panic is acceptable (e.g., immediately after a type switch case). A panic on a type assertion in production code is almost always a bug — it means you had the wrong assumption about what type was in the interface.

Asserting to Interface Types

You can assert to an interface, not just a concrete type. This checks whether the underlying value implements that interface:

type Stringer interface{ String() string }
type Closer  interface{ Close() error }

var v any = os.Stdout  // *os.File implements both

// Check if v implements Stringer
if s, ok := v.(Stringer); ok {
    fmt.Println(s.String())
}

// Check if v implements Closer
if c, ok := v.(Closer); ok {
    c.Close()
}

This is how you check for optional capabilities — “does this value happen to support flushing?” — without requiring it in the primary interface.

Type Switches

A type switch is like a regular switch but each case matches a type rather than a value. It’s the idiomatic way to handle multiple possible types from an interface:

func describe(i any) string {
    switch v := i.(type) {
    case int:
        return fmt.Sprintf("integer: %d", v)
    case string:
        return fmt.Sprintf("string: %q (len %d)", v, len(v))
    case bool:
        return fmt.Sprintf("boolean: %v", v)
    case []int:
        return fmt.Sprintf("int slice with %d elements", len(v))
    case nil:
        return "nil"
    default:
        return fmt.Sprintf("unknown type: %T", v)
    }
}

Inside each case, v has the concrete type — v is int in the case int branch, string in case string, etc. The default case handles anything not explicitly listed.

Multiple types in one case share the same body, but v stays as the interface type (since it could be either):

switch v := i.(type) {
case int, int64:
    // v is still 'any' here — can't use int-specific methods
    fmt.Println("it's some integer", v)
case string:
    fmt.Println(v)  // v is string here
}

Practical: Processing Different Event Types

Type switches shine when a function receives events of different concrete types through a common interface:

type Event interface{ eventType() string }

type ClickEvent  struct { X, Y int; Button string }
type KeyEvent    struct { Key string; Modifiers []string }
type ResizeEvent struct { Width, Height int }

func (e ClickEvent)  eventType() string { return "click" }
func (e KeyEvent)    eventType() string { return "key" }
func (e ResizeEvent) eventType() string { return "resize" }

func handleEvent(e Event) {
    switch evt := e.(type) {
    case ClickEvent:
        fmt.Printf("click at (%d,%d) with %s\n", evt.X, evt.Y, evt.Button)
    case KeyEvent:
        fmt.Printf("key %s with modifiers %v\n", evt.Key, evt.Modifiers)
    case ResizeEvent:
        fmt.Printf("resize to %d×%d\n", evt.Width, evt.Height)
    default:
        fmt.Printf("unknown event: %T\n", e)
    }
}

This is cleaner than a chain of if v, ok := e.(ClickEvent); ok {...} else if ... and more explicit than reflection.

Practical: Unwrapping Errors

Error type assertions are one of the most common uses. The errors.As function does type assertion through a chain of wrapped errors — it’s safer and more powerful than a direct assertion:

type DBError struct {
    Op    string
    Table string
    Err   error
}

func (e *DBError) Error() string {
    return fmt.Sprintf("db %s on %s: %v", e.Op, e.Table, e.Err)
}

func getUser(id int) (*User, error) {
    if err := db.QueryRow(...).Scan(&u); err != nil {
        return nil, &DBError{Op: "SELECT", Table: "users", Err: err}
    }
    return &u, nil
}

// In the handler
user, err := getUser(42)
if err != nil {
    var dbErr *DBError
    if errors.As(err, &dbErr) {
        // dbErr.Op, dbErr.Table are available
        slog.Error("db error", slog.String("op", dbErr.Op), slog.String("table", dbErr.Table))
        http.Error(w, "database error", http.StatusInternalServerError)
    } else {
        http.Error(w, "internal error", http.StatusInternalServerError)
    }
    return
}

errors.As traverses the chain from errors.Unwrap() — it finds *DBError even if err is wrapped several levels deep. Direct assertion err.(*DBError) would fail on a wrapped error.

Similarly, errors.Is checks if a sentinel error appears anywhere in the chain:

if errors.Is(err, sql.ErrNoRows) {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

Practical: Optional Interface Capabilities

Some interfaces are extended optionally. http.ResponseWriter optionally supports http.Flusher for streaming:

func streamResponse(w http.ResponseWriter, events <-chan Event) {
    // Check if this ResponseWriter supports streaming
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming not supported", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")

    for event := range events {
        fmt.Fprintf(w, "data: %s\n\n", event.Data)
        flusher.Flush()  // send immediately, don't buffer
    }
}

Without the assertion, you’d have to add Flush() to http.ResponseWriter — which would break all existing implementations. The optional interface pattern avoids that.

When Type Assertions Are a Code Smell

If you find yourself asserting through any to reach concrete types frequently, it often signals that the interface isn’t covering the right abstraction, or that you’re using any where a typed interface would serve better.

The pattern switch v := i.(type) scattered everywhere suggests the interface contract isn’t expressing what callers actually need. Consider whether the interface should have more methods, or whether the callers should be working with the concrete types directly.

Use any / interface{} when you genuinely don’t know the type at design time — JSON decoded into an unknown structure, middleware passing arbitrary request metadata, generic data containers. For everything else, type your interfaces to match what you actually need.

Summary

  • Use the two-value assertion (v, ok := i.(T)) by default — the single-value form panics on failure
  • Type switches are the idiomatic way to handle multiple concrete types from one interface
  • errors.As is the right tool for error type extraction — it traverses wrapped error chains, direct assertion doesn’t
  • Asserting to an interface (not just a concrete type) checks optional capability — how standard library “upgrading” works
  • Frequent type assertions on any often signal that a typed interface would be clearer and safer

Resources

Comments

👍 Was this article helpful?