Type assertion in Go

Type assertion in Go safely extracts a concrete value from an interface variable using the comma-ok idiom to prevent panics.

The box with a label

You're writing a function that accepts any. The caller passes a string, an int, or a custom struct. Your function needs to do different things based on what arrived. You can't call methods on any. You can't concatenate it. You need to extract the concrete value. Reaching in blindly causes a panic. Type assertion is the safe way to open the box and verify the contents before you touch them.

How interfaces hide data

An interface variable in Go holds two things: the dynamic type and the dynamic value. Think of it as a tagged envelope. The tag says "String" or "Int", and the envelope contains the actual data. When you assign a concrete value to an interface, Go wraps it in this pair.

Type assertion checks the tag. If the tag matches the type you ask for, Go hands you the data. If the tag doesn't match, the assertion fails. You get nothing, or you get a panic, depending on how you write the code.

The syntax is value.(Type). The dot-parentheses look like a function call, but it's a special operator. It tells the compiler you want to extract a specific type from the interface.

The safe pattern

Always use the comma-ok idiom when the type might not match. This form returns two values: the extracted value and a boolean. The boolean is true if the type matches, false otherwise.

Here's the minimal safe assertion. Check the boolean before using the value.

package main

import "fmt"

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

    // Comma-ok idiom checks the type and returns a boolean.
    // If the type matches, ok is true and val holds the concrete value.
    if val, ok := i.(string); ok {
        fmt.Println("Got string:", val)
    } else {
        fmt.Println("Not a string")
    }
}

The variable val is declared inside the if. It only exists in that block. This prevents you from accidentally using an uninitialized value. If the assertion fails, ok is false and val becomes the zero value of string, which is an empty string. The code flows to the else branch. No crash.

Type assertion is a contract check. Verify before you extract.

What happens at runtime

The compiler knows i is any. It allows the assertion syntax because any could hold anything. At runtime, the Go runtime checks the type descriptor inside the interface.

If i holds a string, the assertion succeeds. The variable val is typed as string. You can now call string methods or concatenate. If i holds an int, the assertion fails. ok becomes false. The runtime does not panic. It returns the zero value for val and lets your code handle the mismatch.

This check is fast. It's a pointer comparison and a type ID check. There's no reflection overhead. Type assertion is cheap.

Realistic usage: plugin results

You're building a plugin system. Plugins return results as any. You need to handle Result structs and error strings. A type switch handles multiple types cleanly without nesting.

package main

import "fmt"

type Result struct {
    ID    int
    Score float64
}

// processOutput handles different return types from a plugin.
func processOutput(output any) {
    // Type switch handles multiple types cleanly without nesting.
    // Each case declares a new variable with the concrete type.
    switch v := output.(type) {
    case Result:
        fmt.Printf("Result %d scored %.2f\n", v.ID, v.Score)
    case string:
        fmt.Println("Plugin error:", v)
    case nil:
        fmt.Println("No output")
    default:
        fmt.Println("Unknown output type")
    }
}

func main() {
    processOutput(Result{ID: 1, Score: 99.5})
    processOutput("timeout exceeded")
    processOutput(nil)
    processOutput(42)
}

The type switch uses v := output.(type). The variable v is available in each case with the correct type. In the Result case, v is a Result. In the string case, v is a string. This avoids repeating the assertion syntax.

Go convention favors "accept interfaces, return structs." Here we accept any (an interface) and process concrete structs. The caller returns a struct; the receiver accepts an interface. This keeps the API flexible while the implementation stays concrete.

Type switches scale. Assertions target. Pick the tool that matches the branching logic.

Pitfalls and panics

If you skip the comma-ok, you get a single-ok assertion. This form assumes the type matches. If it doesn't, the program panics.

package main

import "fmt"

func main() {
    var i any = 42

    // Single-ok assertion panics if the type does not match.
    // This is unsafe unless the type is guaranteed.
    val := i.(string)
    fmt.Println(val)
}

The runtime panics with panic: interface conversion: any is int, not string if you assert the wrong type without the boolean check. This crash stops the program. In production, a panic is a bug. The comma-ok idiom is your shield.

Use a single-ok assertion only when the type is guaranteed by the code structure. For example, inside a type switch case, the type is already verified. You can assert again without the boolean if you need the value in a different scope, but usually the case variable is enough.

A panic in production is a bug. The comma-ok idiom is your shield.

Asserting on non-empty interfaces

Type assertion works on any interface, not just any. You can assert a non-empty interface to a concrete type. This checks if the underlying value has that specific type.

Suppose you have an io.Reader. You want to optimize by checking if it's a *bytes.Buffer. If it is, you can read the bytes directly without allocation.

package main

import (
    "bytes"
    "fmt"
    "io"
)

// readAll tries to optimize by checking for a buffer.
func readAll(r io.Reader) []byte {
    // Assert the concrete type to avoid allocation if possible.
    // This checks if the underlying value is a *bytes.Buffer.
    if buf, ok := r.(*bytes.Buffer); ok {
        // Bytes returns the content without copying.
        return buf.Bytes()
    }
    // Fallback to generic reading.
    return nil
}

func main() {
    b := bytes.NewBufferString("data")
    fmt.Println(readAll(b))
}

The assertion r.(*bytes.Buffer) checks if the concrete type inside r is *bytes.Buffer. It does not check if r implements *bytes.Buffer. Interfaces hold values, not other interfaces. The assertion looks at the value.

This pattern is common in performance-critical code. You accept an interface for flexibility, then assert to a concrete type for speed. The fallback handles everything else.

Assertion versus conversion

Beginners often confuse type assertion with type conversion. Conversion changes the value. Assertion extracts the value.

Type conversion uses parentheses: float64(val). It transforms the data. An int becomes a float64. The value might lose precision or change representation.

Type assertion uses dot-parentheses: val.(float64). It reveals the data. If the interface holds a float64, you get it back. No transformation happens.

You cannot convert an interface directly to a concrete type. You must assert first.

package main

import "fmt"

func main() {
    var i any = 42

    // Type assertion extracts the int. No change in value.
    val := i.(int)
    fmt.Println(val)

    // Type conversion changes the value.
    // This converts the int to a float64.
    f := float64(val)
    fmt.Println(f)
}

If you try to convert the interface directly, the compiler rejects it. The compiler complains with cannot convert i (variable of type any) to type float64. You must assert to the concrete type before converting.

Conversion changes the value. Assertion reveals it. Don't confuse the two.

When to use what

Use a comma-ok type assertion when you expect one specific type and need to handle the mismatch gracefully. Use a type switch when you need to branch on multiple possible types in a single block. Use a single-ok assertion when the type is guaranteed by the code structure, such as inside a type switch case or when the interface is defined to hold only that type. Use reflection when you must inspect types dynamically at runtime, accepting the performance cost and reduced readability.

Reflection is a sledgehammer. Use it only when the screwdriver breaks.

Where to go next