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
Tis a concrete type (likeintor*NotFoundError), the stored type must matchTexactly — not merely be “compatible” with it. - If
Tis itself an interface type, the runtime instead checks whether the stored concrete type’s method set includes every methodTrequires — 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
xinx.(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 concreteT, or a method-set satisfaction check ifTis an interface. - On a match, the data word is reinterpreted (or copied, for value types) as a
T, producingv. - On a mismatch, the single-value form calls into the runtime’s panic machinery with a
*runtime.TypeAssertionError; the comma-ok form instead simply setsoktofalseandvtoT‘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 runningdefaultif none match) and bindingvto 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.Asfrom the standard library over a raw type assertion onerr— it also unwraps errors wrapped withfmt.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(notT) satisfies interfaces built from those methods, and only*Tis 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 whethervholds a[]int, returning the slice andtrueif so, ornilandfalseotherwise. Test it with a[]intand with a[]string. - Extend the
describefunction from Example 2 with an additionalcase []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,
CircleandSquare, each with anArea() float64method (so both satisfy aShapeinterface). Write a function that accepts aShapeand uses a type switch to print a shape-specific message (e.g. mention the radius for aCircle, the side length for aSquare) in addition to callingArea().
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 formv, 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
Tvs.*Texactly as the value was stored; assertions to an interface type check method-set satisfaction instead. - For errors, prefer
errors.Asover raw assertions so wrapped errors are unwrapped correctly.
