Type Assertions

A type assertion lets you reach inside a Go interface value and ask what concrete type it is actually holding, then use that concrete type directly. Interfaces in Go only expose the methods they declare, so when you need functionality beyond that — a specific field, a method not in the interface, or to branch behavior by type — a type assertion (or its cousin, the type switch) is the tool. Used carelessly it is also one of the most common sources of runtime panics in Go programs, so understanding exactly how it works is essential.

Overview / How it works

Go interfaces are satisfied implicitly: a type doesn’t declare that it implements an interface, it simply has to have the right method set. That means an interface-typed variable — including the empty interface any (an alias for interface{}) — can hold values of many unrelated concrete types over its lifetime. The compiler only lets you call the methods the interface declares on such a variable, even though at runtime it is holding something far more specific, like a *os.File or a custom struct.

A type assertion, written x.(T), asks the runtime: “does the interface value x currently hold a value whose type is T (or, if T is itself an interface, does the stored type satisfy T)?” If so, it gives you back that value with type T, so you can use everything T offers — not just what the original interface exposed.

To understand why this even needs a runtime check, it helps to know how interface values are represented in memory. An interface variable is a two-word header, not the value itself. For the empty interface any, the Go runtime calls this an eface: one word is a pointer to a type descriptor (metadata describing the concrete type), and the other is a pointer to the actual data. For a non-empty interface (one with methods, like error or a custom interface), the runtime calls it an iface: the first word points to an itab, a small table that pairs the concrete type with the specific interface type and lists the function pointers for that interface’s methods; the second word again points to the data. This is also why interface method calls are effectively an indirect call through that table rather than a direct call.

When you write x.(T), the runtime compares the type descriptor stored inside x‘s header against T:

  • If T is a concrete type (like int or *NotFoundError), the stored type must match T exactly — not merely be “compatible” with it.
  • If T is itself an interface type, the runtime instead checks whether the stored concrete type’s method set includes every method T requires — essentially the same implicit-satisfaction check the compiler does statically, but performed at runtime against a type that wasn’t known until the program ran.

This is different from a type conversion like int(someFloat), which the compiler resolves entirely at compile time between two known concrete types. A type assertion instead operates on an interface value whose concrete type the compiler cannot know in advance, so the check necessarily happens while the program is running.

Syntax

v, ok := x.(T)   // safe form: ok reports success, v is the zero value of T on failure
v := x.(T)       // panics immediately if x does not hold a T

switch v := x.(type) {
case T1:
    // v has type T1 here
case T2:
    // v has type T2 here
default:
    // v keeps x's original interface type here
}
Part Meaning
x An expression whose static type is an interface (e.g. any, error, or a custom interface).
T The target type being tested: a concrete type or another interface type.
v, ok := x.(T) “Comma-ok” form. Never panics; ok is false and v is T‘s zero value when the assertion fails.
v := x.(T) Single-value form. Panics with a *runtime.TypeAssertionError if x does not hold a T.
x.(type) Only legal inside a switch statement’s guard; used to branch on the dynamic type of x.

Examples

Example 1: The comma-ok form

package main

import "fmt"

func main() {
	var i any = "hello"

	s, ok := i.(string)
	fmt.Println(s, ok)

	n, ok := i.(int)
	fmt.Println(n, ok)
}

Output:

hello true
0 false

The variable i has static type any but is dynamically holding a string. Asserting to string succeeds, so s gets "hello" and ok is true. Asserting to int fails because the stored type doesn’t match, so n gets int‘s zero value (0) and ok is false — no panic either way, which is exactly why the comma-ok form is the safe default.

Example 2: Branching with a type switch

package main

import "fmt"

func describe(i any) string {
	switch v := i.(type) {
	case int:
		return fmt.Sprintf("int: %d", v)
	case string:
		return fmt.Sprintf("string: %q", v)
	case bool:
		return fmt.Sprintf("bool: %t", v)
	default:
		return fmt.Sprintf("unknown type: %T", v)
	}
}

func main() {
	fmt.Println(describe(42))
	fmt.Println(describe("go"))
	fmt.Println(describe(true))
	fmt.Println(describe(3.14))
}

Output:

int: 42
string: "go"
bool: true
unknown type: float64

Each case is really its own type assertion, checked in source order; inside that branch, v takes on the case’s specific type, so %d, %q, and %t all work directly with no further conversion. Because float64 was never listed, execution falls through to default, where v keeps the original any type — that’s why %T is used there instead of a type-specific verb.

Example 3: Asserting to a concrete error type

package main

import "fmt"

type NotFoundError struct {
	Name string
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("%s not found", e.Name)
}

func fetch(name string) error {
	if name == "missing" {
		return &NotFoundError{Name: name}
	}
	return nil
}

func main() {
	err := fetch("missing")
	if err != nil {
		if nfErr, ok := err.(*NotFoundError); ok {
			fmt.Println("handling not-found for:", nfErr.Name)
		} else {
			fmt.Println("other error:", err)
		}
	}
}

Output:

handling not-found for: missing

fetch returns a plain error interface, but the concrete value stored inside it is *NotFoundError. Because the function’s declared return type is only error, callers can’t reach NotFoundError‘s Name field without narrowing the type first — the assertion err.(*NotFoundError) does exactly that, letting main handle this particular error kind specially while still falling back gracefully for any other error.

How it works step by step

  • The compiler first checks, at compile time, that x in x.(T) has an interface static type. Asserting on an already-concrete value is a compile error, not something you can catch at runtime.
  • At runtime, Go reads the type descriptor (or itab) stored in x‘s two-word interface header.
  • That stored type is compared against T: an exact match for a concrete T, or a method-set satisfaction check if T is an interface.
  • On a match, the data word is reinterpreted (or copied, for value types) as a T, producing v.
  • On a mismatch, the single-value form calls into the runtime’s panic machinery with a *runtime.TypeAssertionError; the comma-ok form instead simply sets ok to false and v to T‘s zero value — no panic path is ever entered.
  • A type switch performs this same matching process once per case, in the order the cases appear in source, stopping at the first match (or running default if none match) and binding v to that case’s type only inside that branch.

Common Mistakes

Mistake 1: Using the panicking single-value form on untrusted data

var i any = "hello"
n := i.(int) // panics: interface conversion: interface {} is string, not int
fmt.Println(n)

Whenever the concrete type inside an interface value isn’t guaranteed, the single-value assertion is a live grenade: any mismatch crashes the program with a panic. Use the comma-ok form and handle the failure case explicitly instead.

package main

import "fmt"

func main() {
	var i any = "hello"

	n, ok := i.(int)
	if !ok {
		fmt.Println("i is not an int")
	} else {
		fmt.Println("i as int:", n)
	}
}

Output:

i is not an int

Mistake 2: Asserting the wrong pointer/value form of a type

var err error = &NotFoundError{Name: "cache"}
nf, ok := err.(NotFoundError) // ok is false: the stored type is *NotFoundError, not NotFoundError
fmt.Println(nf, ok)

A type assertion to a concrete type requires an exact match, and Go treats T and *T as different concrete types. Here NotFoundError‘s only method is defined with a pointer receiver, so it was stored as *NotFoundError inside the error interface — asserting to the value type NotFoundError simply fails. Assert to the exact stored type instead:

package main

import "fmt"

type NotFoundError struct {
	Name string
}

func (e *NotFoundError) Error() string {
	return fmt.Sprintf("%s not found", e.Name)
}

func main() {
	var err error = &NotFoundError{Name: "cache"}

	nf, ok := err.(*NotFoundError)
	if ok {
		fmt.Println(nf.Name, ok)
	}
}

Output:

cache true

Best Practices

  • Default to the comma-ok form (v, ok := x.(T)) whenever a mismatch is a normal, expected outcome; reserve the panicking single-value form for cases where a mismatch truly represents a programmer bug you want to fail loudly on.
  • Prefer a type switch over a chain of separate if/x.(T) assertions once you’re branching on more than one or two possible types — it’s clearer and only evaluates the underlying type once.
  • Remember that asserting to an interface type checks method-set satisfaction, not identity — many unrelated concrete types can pass the same assertion.
  • For errors specifically, prefer errors.As from the standard library over a raw type assertion on err — it also unwraps errors wrapped with fmt.Errorf("...%w...", err), which a direct assertion will not see through.
  • Keep pointer vs. value receivers in mind: if a type’s methods are defined on the pointer receiver, only *T (not T) satisfies interfaces built from those methods, and only *T is what an assertion to that concrete type will match.
  • Don’t lean on type assertions to paper over a poorly designed interface; if many call sites keep asserting down to the same concrete type, consider whether that behavior belongs on the interface itself.

Practice Exercises

  • Write a function asInts(v any) ([]int, bool) that uses a comma-ok assertion to report whether v holds a []int, returning the slice and true if so, or nil and false otherwise. Test it with a []int and with a []string.
  • Extend the describe function from Example 2 with an additional case []string: that reports the number of elements (e.g. "[]string with 3 elements"), and verify it against a slice like []string{"a", "b", "c"}.
  • Define two small structs, Circle and Square, each with an Area() float64 method (so both satisfy a Shape interface). Write a function that accepts a Shape and uses a type switch to print a shape-specific message (e.g. mention the radius for a Circle, the side length for a Square) in addition to calling Area().

Summary

  • A type assertion, x.(T), extracts the concrete (or a different interface) type out of an interface value at runtime.
  • Interface values are two-word headers (a type/itab pointer plus a data pointer); an assertion compares the stored type against T.
  • The single-value form v := x.(T) panics on a mismatch; the comma-ok form v, ok := x.(T) never panics and is the safer default.
  • A type switch (switch v := x.(type) { ... }) is the idiomatic way to branch across several possible types.
  • Assertions to a concrete type require an exact match, including matching T vs. *T exactly as the value was stored; assertions to an interface type check method-set satisfaction instead.
  • For errors, prefer errors.As over raw assertions so wrapped errors are unwrapped correctly.