Ein Interface-Wert in Go ist eine Zwei-Wort-Box: das eine Wort zeigt auf den konkreten Typ, das andere auf die Daten. Solange du nur die Interface-Methoden brauchst, reicht das. Sobald du aber wissen willst, welcher konkrete Typ in der Box liegt — um eine spezielle Methode aufzurufen, eine Optimierung zu nehmen oder einen Sonderfall zu behandeln —, brauchst du die Type Assertion x.(T) und ihren großen Bruder, den Type Switch. Dieser Artikel arbeitet beide Mechanismen formal aus der Spec heraus, zeigt das comma-ok-Idiom als sicheres Default, ordnet errors.As/errors.Is als idiomatischen Wrapper-Aware-Mechanismus ein und schließt mit zwei Praxis-Beispielen, die du in dieser Form in echtem Code wiederfindest: ein JSON-Tree-Walker und ein Error-Handler mit fehlertypspezifischer Logik.
Was eine Type Assertion ist — formal
Die Go-Spec definiert die Type Assertion knapp und präzise:
For an expression
xof interface type, but not a type parameter, and a typeT, the primary expressionx.(T)asserts thatxis notniland that the value stored inxis of typeT. The notationx.(T)is called a type assertion.
Drei Beobachtungen aus diesem einen Satz:
xmuss ein Interface-Typ sein. Auf konkreten Typen (z. B.int,*Foo) istx.(T)ein Compile-Fehler. Type Assertion ist ausschließlich der Weg raus aus dem Interface.Tdarf entweder ein konkreter Typ sein (x.(*os.File),x.(string)) — dann prüft Go, ob die Box exakt diesen Typ enthält. OderTist ein anderer Interface-Typ (x.(io.Reader)) — dann prüft Go, ob der konkrete Typ in der Box dieses zweite Interface erfüllt.- Die Assertion paniciert, wenn
xnil ist oder der gespeicherte Typ nicht passt. Genau dafür gibt es die zweite, sichere Form.
package main
import "fmt"
func main() {
var x any = "hallo"
// Einwertige Form — Panic bei Typ-Mismatch.
s := x.(string)
fmt.Println(s, len(s))
}hallo 5any ist das Alias für interface{} seit Go 1.18 — leer im Sinne von „enthält keine Methoden in der Signatur", nicht im Sinne von „enthält keinen Wert". In der Box liegen Typ-Wort und Daten-Wort, beide sind hier gesetzt.
Zwei Formen — einwertig vs. comma-ok
Die Type Assertion existiert in zwei Varianten, die sich nur in einem Punkt unterscheiden: was passiert, wenn die Behauptung falsch ist.
Einwertige Form: v := x.(T) paniciert bei Fehlschlag mit dem Laufzeitfehler interface conversion: T is U, not T.
Comma-ok-Form: v, ok := x.(T) paniciert nicht. Bei Erfolg ist ok == true und v enthält den Wert. Bei Fehlschlag ist ok == false und v ist der Zero Value von T — "" für Strings, 0 für int, nil für Pointer-Typen.
package main
import "fmt"
func main() {
var x any = 42
// Erfolgreicher Fall — beide Formen funktionieren.
n := x.(int)
fmt.Println("einwertig erfolgreich:", n)
n, ok := x.(int)
fmt.Println("comma-ok erfolgreich: ", n, ok)
// Falscher Typ — nur comma-ok ist sicher.
s, ok := x.(string)
fmt.Println("comma-ok falsch: ", fmt.Sprintf("%q", s), ok)
// Die einwertige Variante würde hier panicen:
// s := x.(string) // panic: interface conversion: any is int, not string
}einwertig erfolgreich: 42
comma-ok erfolgreich: 42 true
comma-ok falsch: "" falseAuch nil-Interfaces gehen mit comma-ok still um:
package main
import "fmt"
func main() {
var x any // nil-Interface, kein Typ und kein Wert
v, ok := x.(int)
fmt.Println(v, ok) // 0 false — kein Panic
// v2 := x.(int) // panic: interface conversion: interface is nil, not int
}0 falseWann darf es panicen — und wann muss es comma-ok sein
Beide Formen sind idiomatisch. Die Entscheidung ist keine Stil-, sondern eine Fehler-Klassen-Frage: ist ein Mismatch ein Programmierfehler oder ein Eingabe-Fehler?
Panic ist OK, wenn ein falscher Typ nur durch einen Bug entstehen kann. Klassisches Beispiel: Du hast gerade m.Store("key", &User{}) geschrieben und liest gleich darauf v, _ := m.Load("key"); u := v.(*User). Wenn da kein *User drinliegt, ist das ein Bug, kein Eingabe-Fehler — Panic ist das richtige Signal, weil es den fehlerhaften Code laut macht.
comma-ok ist Pflicht, wenn der Wert aus einer Quelle kommt, die unterschiedliche Typen liefern kann: json.Unmarshal in map[string]any, os.Args-Parsing, dynamische Plugin-Werte, Reflection. Hier ist „Typ passt nicht" ein normaler Programmablauf, kein Bug.
// OK: interne Invariante. Wenn das nicht stimmt, ist es ein Bug.
func processInternal(state any) {
s := state.(*internalState) // Panic = klare Bug-Meldung
s.step++
}
// PFLICHT comma-ok: das Format kommt von außen.
func extractName(payload map[string]any) (string, error) {
v, ok := payload["name"]
if !ok {
return "", fmt.Errorf("name fehlt")
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("name muss string sein, war %T", v)
}
return s, nil
}Die Faustregel: Vertrauensbereich entscheidet. Innerhalb deiner eigenen Datenstruktur darfst du Annahmen panicen lassen. An jeder Grenze nach außen — JSON, Netzwerk, Datei, User-Eingabe — gehört comma-ok hin.
Type Assertion auf Interface-Typ — Feature Detection
Ein oft übersehenes Detail: T in x.(T) darf selbst ein Interface-Typ sein. Das ist das Fundament für Feature Detection in der Stdlib. Beispiel: io.Copy möchte wissen, ob die übergebene Source zusätzlich WriteTo unterstützt — wenn ja, kann es eine optimierte Variante nutzen.
package main
import (
"bytes"
"fmt"
"io"
"strings"
)
// copyAware nutzt WriteTo, wenn die Quelle es anbietet — sonst Standard-Loop.
func copyAware(dst io.Writer, src io.Reader) (int64, error) {
// Feature Detection: implementiert src zusätzlich io.WriterTo?
if wt, ok := src.(io.WriterTo); ok {
fmt.Println("Fast-Path: WriteTo verfügbar")
return wt.WriteTo(dst)
}
fmt.Println("Fallback: generische Kopier-Schleife")
return io.Copy(dst, src)
}
func main() {
var dst bytes.Buffer
// strings.Reader implementiert io.WriterTo → Fast-Path.
copyAware(&dst, strings.NewReader("hallo"))
// io.LimitReader implementiert WriteTo NICHT → Fallback.
copyAware(&dst, io.LimitReader(strings.NewReader("welt"), 4))
}Fast-Path: WriteTo verfügbar
Fallback: generische Kopier-SchleifeDas Pattern findest du an Dutzenden Stellen in der Stdlib: io.Copy prüft auf WriterTo und ReaderFrom, fmt-Funktionen prüfen auf error, Stringer, Formatter, database/sql prüft auf driver.Valuer. Effective Go formuliert es so:
The first case finds a concrete value; the second converts the interface into another interface. It's perfectly fine to mix types this way.
Wichtig ist, dass die Feature Detection nie crashen darf — also immer mit comma-ok. Eine fehlende Optional-Schnittstelle ist kein Fehler, sondern „dann eben der Standardweg".
Type Switch — Syntax aus der Spec
Wer mehr als zwei Möglichkeiten unterscheiden will, schreibt nicht eine Kaskade aus comma-ok-Assertions, sondern einen Type Switch. Die Spec:
A type switch compares types rather than values. It is otherwise similar to an expression switch. It is marked by a special switch expression that has the form of a type assertion using the keyword
typerather than an actual type:switch x.(type) { ... }.
Die Schlüsselstelle ist x.(type) — type ist hier ein Spec-Keyword, kein Identifier. Diese Schreibweise gibt es ausschließlich innerhalb des switch-Headers.
package main
import "fmt"
func describe(x any) string {
switch v := x.(type) {
case nil:
return "nil-Interface"
case bool:
return fmt.Sprintf("bool: %t", v)
case int, int32, int64:
// Mehrere Typen pro Case — v hat den Typ des Switch-Werts (any).
return fmt.Sprintf("ein Integer-Typ: %v", v)
case string:
return fmt.Sprintf("string (Länge %d): %q", len(v), v)
case fmt.Stringer:
// Interface-Case — v ist hier fmt.Stringer.
return "Stringer: " + v.String()
default:
return fmt.Sprintf("unbekannt: %T", x)
}
}
type Tag string
func (t Tag) String() string { return "#" + string(t) }
func main() {
fmt.Println(describe(nil))
fmt.Println(describe(true))
fmt.Println(describe(42))
fmt.Println(describe(int64(99)))
fmt.Println(describe("hallo"))
fmt.Println(describe(Tag("go")))
fmt.Println(describe(3.14))
}nil-Interface
bool: true
ein Integer-Typ: 42
ein Integer-Typ: 99
string (Länge 5): "hallo"
Stringer: #go
unbekannt: float64Vier Spec-Details aus dem Beispiel:
case nilprüft, ob das Interface komplett leer ist (kein Typ-Wort gesetzt). Das ist der einzige Weg, das saubere nil-Interface im Type Switch zu fangen.- Mehrere Typen pro Case —
case int, int32, int64:matcht jeden dieser drei. Wichtig: In so einem Case hatvden Typ des Switch-Subjekts (hierany), nicht den Typ aus dem Case. Bei einem einzelnen Typ im Case hatvexakt diesen Typ. - Interface-Cases sind erlaubt.
case fmt.Stringer:matcht jeden Wert, dessen konkreter Typ das Interface implementiert. Die Reihenfolge der Cases ist signifikant — der erste passende Case gewinnt. defaultist optional und matcht alles, was kein anderer Case fängt.
Die Variablendeklaration v := x.(type) ist ebenfalls optional. switch x.(type) { ... } ohne v := ist legal, wenn du den extrahierten Wert nicht brauchst — etwa beim reinen Klassifizieren.
Type Switch vs. Kette aus Assertions
Drei Fälle, drei Assertions, drei comma-ok — geht. Wird aber schnell unübersichtlich, und der Compiler erzeugt für jeden Test einen separaten itab-Vergleich. Der Type Switch ist hier sowohl lesbarer als auch oft schneller.
// VARIANTE A — Kette aus Assertions. Wiederholt sich, jeder Zweig prüft erneut.
func formatA(x any) string {
if s, ok := x.(string); ok {
return s
}
if n, ok := x.(int); ok {
return strconv.Itoa(n)
}
if st, ok := x.(fmt.Stringer); ok {
return st.String()
}
return fmt.Sprintf("%v", x)
}
// VARIANTE B — Type Switch. Ein Subject, klarer Aufbau.
func formatB(x any) string {
switch v := x.(type) {
case string:
return v
case int:
return strconv.Itoa(v)
case fmt.Stringer:
return v.String()
default:
return fmt.Sprintf("%v", x)
}
}Faustregel: Ab drei Fällen lohnt sich der Type Switch. Bei einem oder zwei Fällen ist die Assertion mit if direkter. Vor allem die Feature-Detection-Variante if rs, ok := r.(io.ReaderAt); ok { ... } bleibt als knappes Idiom bestehen — niemand schreibt dafür einen Type Switch.
errors.As und errors.Is — Wrapper-Aware
Bei Fehlern ist eine direkte Type Assertion fast immer falsch. Seit Go 1.13 wickelt das Error-Modell Fehler in Wrapper (fmt.Errorf("kontext: %w", err)), und ein nackter err.(*MyError)-Cast verfehlt jeden gewrappten Fehler.
package main
import (
"errors"
"fmt"
)
type NotFoundError struct {
Key string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("nicht gefunden: %q", e.Key)
}
func lookup() error {
base := &NotFoundError{Key: "user-42"}
return fmt.Errorf("lookup fehlgeschlagen: %w", base)
}
func main() {
err := lookup()
// FALSCH — Type Assertion sieht nur die äußerste Schicht.
if nf, ok := err.(*NotFoundError); ok {
fmt.Println("direkt:", nf.Key)
} else {
fmt.Println("direkt: kein Treffer")
}
// RICHTIG — errors.As läuft die Wrapper-Kette ab.
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println("errors.As: Key =", nf.Key)
}
}direkt: kein Treffer
errors.As: Key = user-42errors.As(err, &target) ist konzeptionell eine Schleifen-Variante der Type Assertion: sie wandert über Unwrap() durch die Fehlerkette und versucht in jeder Schicht, den aktuellen Fehler auf den Typ von *target zu casten. Beim ersten Treffer wird target gesetzt und true zurückgegeben.
Faustregel: Auf error-Werten nie err.(*MyError) schreiben — immer errors.As. Für error == sentinel-Vergleiche analog: immer errors.Is, nie ==.
Performance — itab-Lookup und Branch Prediction
Eine Type Assertion ist nicht gratis, aber sie ist sehr günstig. Was der Compiler erzeugt, hängt von T ab:
Tist ein konkreter Typ: Vergleich des Typ-Worts im Interface gegen das*rtypevonT. Ein einzelner Pointer-Compare, im Hot-Loop praktisch frei.Tist ein Interface-Typ: Lookup einer Methoden-Tabelle (itab) für das Paar(konkreter Typ, Ziel-Interface). Beim ersten Lookup wird die itab gebaut und in einem globalen Cache gespeichert; danach ist es wieder ein Pointer-Compare.
// Hot Loop — konkrete Assertion ist hier billig, fast wie ein Type-Tag-Check.
func sumInts(values []any) int {
total := 0
for _, v := range values {
if n, ok := v.(int); ok {
total += n
}
}
return total
}
// Type Switch über viele Typen — der Compiler emittiert oft eine
// Sprungtabelle / Hash über die Typ-Hashes, nicht eine lineare Kette.
func categorize(v any) string {
switch v.(type) {
case int, int8, int16, int32, int64:
return "int"
case uint, uint8, uint16, uint32, uint64:
return "uint"
case float32, float64:
return "float"
case string:
return "string"
default:
return "other"
}
}Zwei praktische Konsequenzen:
- Im Hot-Path mit immer demselben konkreten Typ ist die Assertion praktisch kostenlos — die Branch Prediction lernt den Pfad und der Compare ist ein einzelner Pointer-Vergleich.
- Im Hot-Path mit vielen unterschiedlichen konkreten Typen pro Iteration wird die Sprungvorhersage ungenauer. Wenn die Performance kritisch ist und der Workload das hergibt, lohnt es, die Werte vorher nach Typ zu sortieren oder zu gruppieren — dann ist der Pfad pro Block wieder vorhersagbar.
In aller Regel ist Type Assertion nicht der Engpass. Wer profilen lässt, wird normalerweise Allocations und Garbage Collection vorher sehen.
Praxis 1 — JSON-Tree-Walker
json.Unmarshal mit einem *any-Ziel produziert eine generische Baumstruktur: Objekte werden zu map[string]any, Arrays zu []any, Zahlen zu float64, Strings zu string, Booleans zu bool, JSON null zu Go nil. Wer so einen Baum traversiert, ohne ein konkretes Schema-Struct, kommt ohne Type Switch nicht aus.
package main
import (
"encoding/json"
"fmt"
"strings"
)
// walk druckt jeden Knoten mit Einrückung und führt die Rekursion
// sauber über alle JSON-Typen.
func walk(v any, depth int) {
indent := strings.Repeat(" ", depth)
switch t := v.(type) {
case nil:
fmt.Printf("%snull\n", indent)
case bool:
fmt.Printf("%sbool: %t\n", indent, t)
case float64:
// json.Unmarshal in any liefert IMMER float64 für Zahlen,
// egal ob die Quelle 42 oder 3.14 war.
fmt.Printf("%snumber: %v\n", indent, t)
case string:
fmt.Printf("%sstring: %q\n", indent, t)
case []any:
fmt.Printf("%sarray (len=%d):\n", indent, len(t))
for _, item := range t {
walk(item, depth+1)
}
case map[string]any:
fmt.Printf("%sobject (keys=%d):\n", indent, len(t))
for k, val := range t {
fmt.Printf("%s %s:\n", indent, k)
walk(val, depth+2)
}
default:
fmt.Printf("%sunbekannter Typ %T\n", indent, t)
}
}
func main() {
raw := []byte(`{
"name": "Alice",
"active": true,
"age": 30,
"tags": ["go", "rust"],
"meta": {"role": "admin", "team": null}
}`)
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
panic(err)
}
walk(tree, 0)
}Drei Beobachtungen aus diesem Praxis-Code:
- Alle JSON-Zahlen werden zu
float64. Wer Ganzzahlen erhalten will, muss entweder ein Schema-Struct nutzen oderjson.Decoder.UseNumber()einschalten — dann liefert der Decoderjson.Numberstattfloat64, und der Type Switch braucht einen entsprechenden Case. map[string]anyund[]anysind die Container-Typen — du musst beide explizit als eigene Cases haben, weil der Type Switch keine generische „irgendein Container"-Erkennung kennt.- Default-Case fängt ab, was du noch nicht modelliert hast. Bei Refactoring später ist das die Stelle, an der Bugs sichtbar werden —
unbekannter Typ XYZist eine bessere Diagnose als ein schweigender Drop.
Praxis 2 — Error-Handling mit Type Switch und errors.As
In einem realistischen HTTP-Handler willst du je nach Fehlertyp einen anderen Status-Code setzen. Type Switch direkt auf err reicht nicht, weil Fehler gewrappt sind — also kombinierst du errors.As für die Wrapper-Logik mit einem Type Switch für die saubere Mehrfach-Diskriminierung.
package main
import (
"errors"
"fmt"
"net/http"
)
// Domain-Fehlertypen — jeder darf eigene Felder transportieren.
type NotFoundError struct {
Resource, Key string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %q nicht gefunden", e.Resource, e.Key)
}
type ValidationError struct {
Field, Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("ungültig %s: %s", e.Field, e.Reason)
}
type RateLimitError struct {
RetryAfter int
}
func (e *RateLimitError) Error() string {
return fmt.Sprintf("rate limit, retry in %ds", e.RetryAfter)
}
// mapError klassifiziert einen Fehler und liefert HTTP-Status + Body.
// Wichtig: errors.As schaut durch die Wrapper-Kette.
func mapError(err error) (int, string) {
var nf *NotFoundError
var ve *ValidationError
var rl *RateLimitError
switch {
case errors.As(err, &nf):
return http.StatusNotFound,
fmt.Sprintf("404: %s %q", nf.Resource, nf.Key)
case errors.As(err, &ve):
return http.StatusUnprocessableEntity,
fmt.Sprintf("422: feld=%s grund=%s", ve.Field, ve.Reason)
case errors.As(err, &rl):
return http.StatusTooManyRequests,
fmt.Sprintf("429: retry-after=%d", rl.RetryAfter)
default:
return http.StatusInternalServerError,
fmt.Sprintf("500: %v", err)
}
}
func loadUser(id string) error {
base := &NotFoundError{Resource: "user", Key: id}
return fmt.Errorf("loadUser: %w", base) // gewrappt!
}
func main() {
cases := []error{
loadUser("u-42"),
&ValidationError{Field: "email", Reason: "leerer Wert"},
fmt.Errorf("limit: %w", &RateLimitError{RetryAfter: 30}),
errors.New("etwas anderes"),
}
for _, e := range cases {
status, body := mapError(e)
fmt.Printf("%d %s\n", status, body)
}
}404 404: user "u-42"
422 422: feld=email grund=leerer Wert
429 429: retry-after=30
500 500: etwas anderesBemerkenswert ist die Wahl von switch { case ... } (Boolean-Switch) statt eines echten Type Switches: weil errors.As die Wrapper-Logik braucht, kannst du nicht einfach switch e := err.(type) schreiben — das würde den äußeren *fmt.wrapError matchen, nicht den dahinterliegenden Domain-Fehler. Der Boolean-Switch mit errors.As-Calls ist das idiomatische Pattern für Fehler-Klassifikation seit Go 1.13.
Besonderheiten
case nil im Type Switch fängt nur das saubere nil-Interface.
Ein (*MyError)(nil), das in einer error-Variable steckt, ist kein nil-Interface — das Typ-Wort ist gesetzt, nur das Daten-Wort ist nil. case nil: matcht das nicht, du landest im case *MyError: mit einem nil-Pointer-Wert. Diese Falle ist exakt das, was den klassischen „typed nil"-Bug erzeugt, der in vielen Go-Codebases einmal einschlägt.
Bei mehreren Typen pro Case behält v den Subject-Typ.
case int, int64: matcht beide, aber v ist innerhalb dieses Case der Typ des Switch-Subjekts (meist any), nicht int. Wer eine typspezifische Operation braucht, muss innerhalb des Case noch einmal eine Assertion machen — oder die Cases trennen, damit der Compiler v als konkreten Typ binden kann.
Reihenfolge bei Interface-Cases ist signifikant.
Wenn case io.Reader: vor case *bytes.Buffer: steht, fällt jeder Buffer in den ersten Case — *bytes.Buffer erfüllt io.Reader. Der Type Switch geht die Cases von oben nach unten, der erste passende gewinnt. Spezifische Typen vor allgemeineren Interfaces einsortieren.
Auf error niemals err.(*MyError) — immer errors.As.
Ab Go 1.13 sind Fehler typischerweise gewrappt (fmt.Errorf("...: %w", err)). Die Type Assertion sieht nur die äußerste Schicht und übersieht den Domain-Fehler darunter. errors.As läuft die Kette ab und füllt das Ziel-Pointer-Argument, sobald ein Treffer kommt.
Type Assertion nur auf Interface-Werten — Compile-Fehler sonst.
var x int = 42; y := x.(int64) ist ein Compile-Fehler: x ist kein Interface-Typ. Type Assertion ist exakt der Weg raus aus einem Interface. Wer einen konkreten Typ in einen anderen konvertieren will, nutzt die normale Conversion T(x).
Comma-ok auf nil-Interface ist sicher — einwertig paniciert.
var x any; v, ok := x.(int) liefert 0, false. var x any; v := x.(int) paniciert mit interface conversion: interface is nil, not int. Wenn du nicht sicher weißt, dass das Interface gesetzt ist, ist comma-ok die einzige sichere Variante.
Type Switch ohne v ist erlaubt — sinnvoll bei reinem Klassifizieren.
switch x.(type) { case int: return "i"; case string: return "s" } ist legal. Wer den Wert nicht braucht, sondern nur den Typ-Namen oder eine Kategorie, spart sich die Binding-Klausel. Das ist auch der Hinweis, dass type hier ein Keyword ist, kein Identifier.
Type Assertion bricht das Interface-Versprechen — vorsichtig damit.
Eine Funktion, die io.Reader annimmt und dann r.(*os.File) casted, hat ihre Signatur erweitert um eine versteckte Anforderung. Saubere API-Praxis: entweder eine engere Schnittstelle akzeptieren, oder per Feature Detection mit comma-ok ein optionales Verhalten anbieten, ohne den Fallback zu verlieren. Type Assertion ist eine Spezial-Optimierung, kein Ersatz für saubere Interface-Wahl.
Weiterführende Ressourcen
Externe Quellen
- Type assertions — Go Language Specification
- Type switches — Go Language Specification
- Effective Go: Interface conversions and type assertions
errors.As— Standardbibliothekerrors.Is— Standardbibliothek- Go 1.13 Error Wrapping — Blog
Verwandte Artikel
- Interfaces — Übersicht und Method-Sets
- Implizite Implementierung — Duck Typing in Go
- Das leere Interface —
anyund seine Tücken - Interface Satisfaction — Method-Sets,
*TvsT - nil-Interface-Fallen — typed nil und Vergleichs-Bugs
- Accept Interfaces, Return Structs — API-Design-Regel
- Pointer vs. Wert — Receiver und Method-Sets