Skip to content
gohacks.dev
Basics

Type Assertions and Type Switches in Go With Examples

Get the concrete type back out of an interface with x.(T) and a type switch. Covers the comma ok form, the panic and optional methods.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

A value stored in an interface remembers its concrete type, and Go gives you two tools to ask what it is: the type assertion and the type switch. They are how you get from an any back to a string, how an encoder decides how to write a value and how code checks whether a writer also supports a faster method. They are also the tools that turn a typed program into a run-time guessing game when overused. This how-to shows each form with its output, including the panic you should expect to see once, and the one case, errors, where a different function is the right choice. It builds on interfaces in Go and the empty interface, and it belongs to the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The assertion, and what happens when it is wrong

The expression x.(T) says "I believe the interface value x holds a T". If it does, you get the T. If it does not, the program panics:

main.goGo
package main

import "fmt"

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

	s := x.(string)
	fmt.Println(s)

	n, ok := x.(int)
	fmt.Println(n, ok)

	n = x.(int)
	fmt.Println(n)
}
Text
hello
0 false
panic: interface conversion: interface {} is string, not int

goroutine 1 [running]:
...

The first assertion succeeds. The comma ok form on the second returns 0 false instead of panicking. The third is the single-value form on the wrong type, and it stops the program with panic: interface conversion: interface {} is string, not int. The message is the whole diagnosis: it names the static type of the interface, the type it found, and the type you asked for.

Use the single-value form only where a wrong type means a bug in your own program and a crash is the right response. For anything that comes from outside, a decoded document, a plugin or a callback, use the comma ok form and handle the miss. The same distinction applies to a map lookup, and checking whether a key exists uses the same two shapes.

Switching over several types

When more than two types are possible, a type switch is cleaner than a ladder of assertions. The syntax switch v := x.(type) declares v in each case with the type that matched:

main.goGo
package main

import (
	"bytes"
	"errors"
	"fmt"
	"strconv"
	"time"
)

func describe(v any) string {
	switch x := v.(type) {
	case nil:
		return "nothing"
	case int, int64:
		return fmt.Sprintf("integer %v", x)
	case string:
		return "string of length " + strconv.Itoa(len(x))
	case []any:
		return fmt.Sprintf("list of %d items", len(x))
	case error:
		return "error: " + x.Error()
	case fmt.Stringer:
		return "stringer: " + x.String()
	default:
		return fmt.Sprintf("something else (%T)", x)
	}
}

func main() {
	var typedNil *bytes.Buffer
	values := []any{nil, 42, int64(7), "héllo", []any{1, 2}, errors.New("boom"), time.Second, 3.14, typedNil}
	for _, v := range values {
		fmt.Println(describe(v))
	}
}
Text
nothing
integer 42
integer 7
string of length 6
list of 2 items
error: boom
stringer: 1s
something else (float64)
stringer: <nil>

Each line of output comes from the first case that matches, in order, so the order of cases matters when types overlap. A time.Duration is not an error, but it has a String method, so it falls into the fmt.Stringer case, and so does the typed nil *bytes.Buffer at the end of the list. Two details are worth remembering.

In a case that lists several types, such as int, int64, the variable keeps the interface's static type, here any. The switch cannot give you an int and an int64 at once, which is why the Sprintf there uses %v. If you need the integer value, split the cases.

case nil matches only an interface that holds no value. The typed nil pointer in the list, typedNil, has a dynamic type, *bytes.Buffer, so it skips case nil and lands in the Stringer case, where String runs on a nil receiver. That is safe here because the bytes.Buffer documentation promises <nil> for a nil pointer. It is not safe in general. I first wrote this example with a nil *time.Time, and it panicked inside the switch with value method time.Time.String called using nil *Time pointer, because a value-receiver method cannot run on a nil pointer. That is the nil interface trap showing up inside a switch, and it is the reason case nil alone is not a null check for pointers.

Testing for an optional method

Asserting to an interface type, not a concrete one, asks "does this value also support X". It is the main way the standard library layers optional fast paths on top of small interfaces. io.WriteString works this way: if the writer has a WriteString method it calls it, and otherwise it falls back to Write with a byte slice conversion. Here is the same pattern, with a writer that counts which path was used:

main.goGo
package main

import (
	"fmt"
	"io"
	"strings"
)

type plain struct{ b strings.Builder }

func (p *plain) Write(data []byte) (int, error) { return p.b.Write(data) }

type fast struct {
	plain
	stringCalls int
}

func (f *fast) WriteString(s string) (int, error) {
	f.stringCalls++
	return f.b.WriteString(s)
}

func emit(w io.Writer, s string) {
	if sw, ok := w.(io.StringWriter); ok {
		sw.WriteString(s)
		return
	}
	w.Write([]byte(s))
}

func main() {
	p := &plain{}
	f := &fast{}
	emit(p, "slow path\n")
	emit(f, "fast path\n")

	fmt.Print(p.b.String(), f.b.String())
	fmt.Println(f.stringCalls)
}
Text
slow path
fast path
1

emit takes the smallest interface it needs, io.Writer, and upgrades when the value offers more. The fast type embeds plain to inherit Write and adds WriteString. The assertion succeeded for it and failed for plain, with no change to either type's declaration. Remember the rule from the interfaces guide: the interface you assert to is checked against the method set of the dynamic type, so pointer receivers matter. I ignored the return values of WriteString and Write in emit to keep the example short, which a real function would not do.

Walking a decoded document

The use case behind most type switches is data whose shape is not known at compile time. encoding/json decodes into any, and the result is built from map[string]any, []any, string, float64, bool and nil, so a recursive function with a switch can process any document:

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"sort"
)

func walk(v any, depth int) {
	indent := fmt.Sprintf("%*s", depth*2, "")
	switch x := v.(type) {
	case map[string]any:
		keys := make([]string, 0, len(x))
		for k := range x {
			keys = append(keys, k)
		}
		sort.Strings(keys)
		for _, k := range keys {
			fmt.Printf("%s%s:\n", indent, k)
			walk(x[k], depth+1)
		}
	case []any:
		for i, item := range x {
			fmt.Printf("%s[%d]\n", indent, i)
			walk(item, depth+1)
		}
	case string:
		fmt.Printf("%s%q\n", indent, x)
	case float64:
		fmt.Printf("%s%g\n", indent, x)
	case bool, nil:
		fmt.Printf("%s%v\n", indent, x)
	}
}

func main() {
	var doc any
	err := json.Unmarshal([]byte(`{"name":"ada","tags":["a","b"],"meta":{"admin":true,"score":9.5,"note":null}}`), &doc)
	if err != nil {
		panic(err)
	}
	walk(doc, 0)
}
Text
meta:
  admin:
    true
  note:
    <nil>
  score:
    9.5
name:
  "ada"
tags:
  [0]
    "a"
  [1]
    "b"

Every JSON number arrives as float64, which is why the number case is float64 and not int. Keys are sorted because map iteration is random, and an output you would diff needs a stable order, as the iteration order article explains. When the document has a known shape, decode into a struct and skip all of this: the compiler checks a struct, and a type switch only checks at run time.

Errors are a special case

You can assert on an error, if pe, ok := err.(*fs.PathError); ok, and it works for the error exactly as returned. The moment a layer wraps it with fmt.Errorf("...: %w", err), the assertion fails, because the outer value is a different type. errors.As(err, &target) unwraps the chain and tests each link, so it is the right tool for errors. Reserve type assertions on errors for places where you control that no wrapping happens. The pattern and the difference from errors.Is are in errors Is and errors As.

When a switch is the wrong tool

If you find a type switch repeated in several places over the same set of types, the abstraction is leaking, and a method on an interface is cleaner: each type does its own work and the switch disappears. Type switches are fine at boundaries where the set of types is closed and small, such as an encoder or a decoder, and they are risky in the middle of business logic where adding a type means finding every switch. Go 1.18 generics also remove some uses: a function that handles int and float64 identically can use a type parameter, not a switch. The specification on type assertions and type switches define the exact rules, and the Go tour page on type switches is a short interactive version of the first example.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.