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 int32 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;
        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. The converter boxes an untyped constant passed as an interface at nint (do(1) emits @do((nint)1); see Empty Interface), but a C# int boxed into an interface by hand-written code has 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:) running the same Go body, to catch both boxings; a single-type case repeats the arm, as above, because its variable is bound at each label’s own type. 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). uint + uintptr does not collide: uintptr is golib’s distinct uintptr struct (see Constant Values), so both cases emit their own labels, each dynamic type routing to its own body exactly as in Go. The merge machinery serves any pair of Go types that shares a C# type — two methodless named func types with one signature, which both render as their base delegate (type Left func(bool) bool and type Right func(bool) bool are both Func<bool, bool>): 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:

switch (raw.type()) {
case Func<bool, bool>: {
    return "either";
}
/* case Func<bool, bool>: merged with an earlier case mapping to the same C# type (identical body) */}

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 (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 TestTypeSwitchCollapsedFuncCase (an identical-body pair that merges, a differing-body pair that keeps both labels, and two different delegates that never merge); the TypeSwitch behavioral test keeps uint + uintptr with different bodies, each value hitting its own case.

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. Splitting such a clause into one concrete-bound C# case per listed type would break every body use in an interface-typed context — as an argument (isGeneric(t) with t a ж<Alias>, CS1503), an interface assignment (CS0266), and an extension-method receiver (CS1929). A multi-type clause 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). 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, over one body. 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.) 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 (or a value adapter’s IValueAdapter.Value) — 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. An interface holding a nil *T (in Go a non-nil interface that matches case *T: binding a nil t) takes the same path: the pointer adapter wraps the canonical typed-nil box ж<T>.NilBox, never a null, so .type() unwraps it, case *T: matches with a nil pointee, and case null: does not. (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; and the TypeSwitchNilPointer behavioral test — a nil *T in a non-empty interface and in any, a binding and a multi-type case, and a pointer-receiver call on the nil pointee.)

Both unwrap tiers sit behind ONE IGoAdapter probe. .type() is evaluated once per type switch, and testing IInterfaceAdapter and then IжAdapter separately would cost two failing interface type tests for every ordinary Go value, which is what the overwhelming majority of type switches dispatch on. Measured on windows/amd64 (.NET 9), 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 adapter interfaces (IInterfaceAdapter, IжAdapter and IValueAdapter) share an empty base marker, IGoAdapter, and .type() probes it once to gate every unwrap, which took the type switch from 16.0 ns to 4.5 ns per iteration on PerfIface (windows/amd64, .NET 9). The string → @string arm sits 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:) would therefore miss every pointer-sourced implementation. Every interface label, named or anonymous, emits the when-guard form — 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) through its package’s registration method, which a [ModuleInitializer] queues and golib runs on the registry’s first lookup that misses (see Run-time registrations wait for the first lookup that misses), so the lookup is a dictionary hit and a compiled factory, reflection-free and Native-AOT-safe (the queued registration also roots the adapter against trimming); an anonymous interface falls back to its runtime duck-typing shell (built by golib’s AdapterBinder). 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. A named interface with no registered adapter for the pair resolves at run time through AdapterBinder, to the same duck-typing shell an anonymous interface uses (see Interfaces), so the pairs the registry cannot hold still match by method set: a (struct, iface) pair with no conversion site anywhere in the program, a generic struct’s adapter (an open registration key is unrepresentable), and FOREIGN value adapters (ᴠ-composed), which do not register. Only a pair no shell can bind is a MISS (NamedInterfaceAdapterIdentity’s case Greeter: matches a pair nothing converts at compile time). (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.)


← Expression Switch Statements · Index · Struct Types →