Type Switch Statements
Reference index · Summary of this topic
For a Go type-switch, C#’s type-pattern switch works well. The runtime exposes the dynamic type via .type(), and the empty interface is any:
func do(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Twice %v is %v\n", v, v*2)
case string:
fmt.Printf("%q is %v bytes long\n", v, len(v))
default:
fmt.Printf("I don't know about type %T!\n", v)
}
}
converts to:
internal static void @do(any i) {
switch (i.type()) {
case nint v: {
fmt.Printf("Twice %v is %v\n"u8, v, v * 2);
break;
}
case @string v: {
fmt.Printf("%q is %v bytes long\n"u8, v, len(v));
break;
}
default: {
var v = i.type();
fmt.Printf("I don't know about type %T!\n"u8, v);
break;
}}
}
Go int/uint cases and the synthetic concrete case. A Go int maps to C# nint, but an int-valued literal boxed into an interface (do(1)) has C# dynamic type int32, not nint. So a case int: emits its native form plus a synthetic concrete case int32: (and case uint: adds case uint32:) sharing the same body, to catch both boxings. The exception: if the same switch also lists an explicit case int32:/case uint32: (or case rune: ≡ int32), the synthetic is skipped — emitting it would duplicate the explicit case (CS8120 “unreachable case”) and, being emitted first, would steal the explicit case’s values and run the wrong body. With the synthetic suppressed, a typed int value (nint) hits case int: and a typed int32 value hits case int32:, distinctly. (Runtime’s printpanicval switches over int, int8, …, int32, …, uint, …, uint32, …; guarded by the TypeSwitch behavioral test.)
Duplicate-mapped cases — the identical-body merge. Go type distinctions can vanish in the C# type map, making a later case unreachable (CS8120). The canonical example was uint + uintptr under the old System.UIntPtr alias — now moot: uintptr is a distinct golib struct (see Constant Values) and both cases emit their own labels, each dynamic type routing to its own body exactly as in Go. The merge machinery remains for any alias pair that still shares a C# type: a duplicate-mapped case merges only when its Go body is byte-identical to the first occurrence’s — the earlier label already routes both dynamic types to that shared body, so the merge is exact. A marker comment replaces the duplicate label:
case uint64 vΔ1: {
print(vΔ1);
break;
}
/* case uintptr vΔ1: merged with an earlier case mapping to the same C# type (identical body) */
case float32 vΔ1: {
If the bodies differ, both labels are kept and the CS8120 stands: a compile error is preferable to silently routing one Go case’s values into another case’s body. Duplicate detection keys on the resolved C# type (uintptr→nuint, rune→int32, byte→uint8) per switch statement; the synthetic int32/uint32 cases register too, so an explicit later duplicate of a synthetic with the same body also merges. Guarded by the TypeSwitch behavioral test (uint + uintptr with identical bodies, values hitting both Go paths).
Multi-type cases stack labels and bind at the tag’s interface type. Go binds a type-switch case
variable at the listed CONCRETE type only when the clause lists exactly one type; a multi-type clause
(case *Alias, *Named:) binds it at the TAG’s static (interface) type. The old emission split such a
clause into one concrete-bound C# case per listed type (duplicating the body), so every body use in an
interface-typed context broke — as an argument (isGeneric(t) with t a ж<Alias>, CS1503), an
interface assignment (CS0266), and an extension-method receiver (CS1929 — 18 errors in go/types alone).
A multi-type clause now emits stacked labels binding only a discard, over one shared body and
re-binds the variable to the guard expression — the same re-bind the default arm uses — so the body
compiles at the interface type exactly as in Go:
switch (x.typ.type()) {
case ж<Alias> _:
case ж<Named> _: {
var t = x.typ; // t: Type (the tag's interface type), as in Go
if (isGeneric(t)) { … }
break;
}
case ж<ΔSignature> t: { // single-type case keeps the concrete binding
The _ designation is load-bearing, not stylistic: it forces the label into PATTERN context. A bare
case int8: label resolves the identifier as an EXPRESSION first, where using static go.builtin finds
the same-named conversion FUNCTION (int8(…)) — a method group, neither constant nor type — failing
CS8917 (encoding/binary’s Size/intDataSize stacks; caught by the census build, not the behavioral
corpus, whose labels happened not to collide). case nil stacks as case null:, a dynamic-interface
label stacks in its non-binding {} ᴛn when ᴛn._<Iface>(out var _) form, and the synthetic
int32/uint32 companions of case int:/case uint: stack with the same discard. The
duplicate-mapped-case merge applies per label (a merged label leaves its marker comment above the stack).
An UNBOUND multi-type clause (switch x.(type)) stacks the same way with no re-bind — the body is no
longer duplicated per label. The re-bind re-evaluates the guard EXPRESSION at body entry — harmless
for a pure tag (nothing can mutate it between dispatch and entry), and an IMPURE tag is hoisted into
a one-time temporary first (see The type-switch tag evaluates exactly once below). (Guarded by the TypeSwitchMultiCase behavioral test — bound multi-type
cases over values and pointers with interface-dispatched body uses, nil stacked with a concrete type, an
unbound multi-type clause, and the synthetic-int stacking, output-compared vs Go; also rewrote
TypeSwitch’s case int, int64, uint64: golden with output proven unchanged.)
Runtime dispatch — .type() unwraps the interface adapters. The case patterns match against
whatever object the switch operand .type() returns, so it must surface the Go DYNAMIC value, not
the C#-only wrapper classes the runtime uses to carry it. A non-empty interface value created from
a Go pointer is a generated IжAdapter wrapping the receiver box (var v shape = &c emits
new circleжshape(Ꮡc); see Interfaces), and an interface-to-interface
assignment can wrap the source in an IInterfaceAdapter. .type() therefore unwraps —
IInterfaceAdapter.Value chains first, then IжAdapter.Box — mirroring the type-assert machinery
in _<T>, so a Go case *circle: (emitted case ж<circle> t:) matches the adapter-wrapped value
exactly as it matches the raw box that an EMPTY interface (any) holds directly. The bound t
IS the original receiver box, so writes through it (t.Value.r += 10) alias the original object,
matching Go’s interface-holds-the-pointer semantics; case nil (emitted case null:) still sees
the nil interface unchanged. A known edge remains: an interface holding a nil *T (in Go a
non-nil interface that matches case *T: binding a nil t) stays wrapped — no C# type pattern
can bind a null — so it falls to default rather than wrongly matching case null:. (Guarded by
the TypeSwitchPointerAdapter behavioral test — pointer-receiver implementations of a non-empty
interface dispatched through single-type, multi-type, no-bind, and write-through cases, plus
value-receiver, nil, and raw-box-in-any controls.)
Both unwrap tiers sit behind ONE IGoAdapter probe (2026-07-26). .type() is evaluated once
per type switch, and it used to test IInterfaceAdapter and then IжAdapter separately — two
failing interface type tests for every ordinary Go value, which is what the overwhelming majority
of type switches dispatch on. Measured, that is ~2.9 ns each on the JIT, against a failing sealed-
class test too small to measure: a failing interface isinst walks the type’s interface map. The
two adapter interfaces now share an empty base marker, IGoAdapter, and .type() probes it once to
gate both unwraps, dropping the type switch from 16.0 ns to 4.5 ns per iteration on PerfIface.
The unwrap ORDER and results are unchanged; the string → @string arm moved after the IжAdapter
arm, which cannot change an answer because string is sealed and implements neither marker. See
Interfaces for the same gate on the type-assert side and the full measurement.
Named-interface case labels dispatch by method set through the adapter registry. An
INTERFACE-typed case label — named (case fmt.Stringer:, case error:) or anonymous — must match
by Go METHOD-SET semantics, and after the unwrap above the operand is the raw receiver box, which
never nominally implements a C# interface (the generated pointer adapter does). A plain C# type
pattern (case Stringer t:) therefore missed every pointer-sourced implementation. All interface
labels now emit the when-guard form the anonymous labels already used —
case {} Δx when Δx._<Stringer>(out var x): — routing dispatch through golib’s type-assert
machinery, which resolves in order: a nominal implementer (value structs made partial, adapter
instances, duck-type wrappers) matches directly; a raw box ж<X> (or an adapter asserting to a
different interface, via its Box) re-wraps through go.AdapterRegistry — each generated
pointer adapter registers (typeof(ж<X>), typeof(Iface)) → box => new XжIface(box) from a
[ModuleInitializer], so the lookup is a dictionary hit and a compiled factory, reflection-free
and Native-AOT-safe (the initializer also roots the adapter against trimming); an anonymous
interface falls back to its runtime duck-typing shell (this said “its generated ᴛAs duck-typing
conversion” until 2026-07-25, when anonymous interfaces moved onto the same shells named ones use).
The out var x binds at
the CASE interface type exactly as Go binds the case variable, the re-wrapped adapter forwards to
the original box (writes through the binding alias the original object), label order is preserved
(C# tests patterns top-to-bottom, and a when-guarded pattern never makes a later label
unreachable), and case nil is unaffected ({} never matches null). The type-assert core is
non-throwing (TryTypeAssert), so a non-matching label — the NORMAL control flow in a type
switch — costs no exception; this also makes a nil-interface v, ok := x.(T) return ok=false
(Go semantics) instead of faulting, and a named interface with no registered adapter is a MISS
rather than the former missing-conversion-method hard error. Known residuals, all of the same shape (the
adapter type does not exist or its module never loaded, so Go would match where C# misses): a
(struct, iface) pair with no conversion site anywhere in the program, a generic struct’s
adapter (an open registration key is unrepresentable and a generic class cannot host a module
initializer), and FOREIGN value adapters (ᴠ-composed), which are not yet registered.
(Guarded by the TypeSwitchNamedInterfaceCase behavioral test — pointer-adapter value, raw
box-in-any, value-struct implementer, case error: in both adapter-carried and raw-box forms,
non-matching control, label-order precedence both directions, a multi-type clause of two interface
labels, interface-tag-to-interface-label dispatch, and write-through aliasing, output-compared
vs Go.)