Multi-Result Values and Comma-Ok Forms

Reference index · Summary of this topic Many Go functions return either a single value or a “value, ok”/”value, error” tuple, where only the declared return arity selects the behavior. You cannot differentiate C# overloads by return type alone, so the runtime types expose a second overload distinguished by an extra discard argument. For map access, the “comma-ok” read routes through a two-value indexer using the discard sentinel ꟷ:

var v1 = m["Answer"];            // single value: zero value if the key is absent
var (v2, ok) = m["Answer", ꟷ];   // comma-ok: (value, present?)

These two forms can behave differently — case in point, type assertions: the single-value form panics on failure, while the comma-ok form returns safely with a boolean success result. Type assertions convert similarly, through a generated _<T>() accessor:

var t = i._<MyType>();              // panics on failure
var (t, ok) = i._<MyType>(ᐧ);       // comma-ok, safe

The asserted type is a type position, so an assertion to a pointer type renders the pointer type ж<T>, not a value dereference: i.(*box) → i._<ж<box>>(). (The starred-operand-is-a-type case previously emitted the type form only inside a (*T)(p) cast; a non-parenthesized *type fell through to the value-deref path and emitted box.Value — CS0426, since Value is not a member type of box.) (Guarded by the TypeAssert behavioral test’s *box assertion; runtime hit this in netpoll.go’s arg.(*pollDesc).)

An assertion to a NAMED interface must record the concrete implementation, even from a non-empty interface source. At run time _<T>() resolves a NAMED target interface only through a compile-time GoImplement adapter (golib TryTypeAssert’s structural duck-typing path exists only for anonymous interfaces — a named interface with no generated ᴛAs method is treated as a miss, not converted). So h.(encoding.BinaryMarshaler) — where h is a hash.Hash32 whose dynamic type is *digest — needs [assembly: GoImplement<digest, encoding.BinaryMarshaler>(Pointer = true)] or it panics at run time (interface conversion: … not encoding.BinaryMarshaler), after compiling cleanly. The converter recorded such implementations only for an empty-interface source; a non-empty interface source (the common hash.Hash32/sort.Interface/… case) recorded nothing, because getUnderlyingType on an interface-typed expression yields the interface, not the concrete dynamic type. convTypeAssertExpr now, for a non-empty interface source asserting to a named target interface, enumerates the package’s concrete types that implement both the source and target interfaces — exactly the dynamic types the assertion can succeed on — and records a GoImplement for each (probing the value then the pointer form, so a pointer-receiver MarshalBinary records against *digest). The both-interface filter keeps the set tight (adler32 → just digest); an anonymous target interface is excluded (it resolves through its ᴛAs method and needs no adapter, so recording one would be dead machinery). Known limitation: only the current package’s scope is scanned, so an external p_test assertion whose concrete type lives in the package under test, or a dynamic type imported from a third package, is not yet covered. This unblocked the hash/* marshal round-trips (TestGoldenMarshal). (Guarded by the InterfaceToInterfaceAssertion behavioral test — a Stringish-typed value asserted to a named Marshaler it implements, plus a comma-ok miss on a type that does not, output-compared vs go run; the pre-fix converter panics at the assertion.)

A named interface’s DECLARATION site also records its same-package structural implementers. The assertion-site recorder above fires only where a package itself performs the assertion, so it generates an adapter only when the ASSERTING package can NAME the concrete dynamic type. When it cannot, the code compiles yet the assertion misses at run time: math/bits asserts err.(runtime.Error) on runtime’s overflowError/divideError panic values, whose dynamic type is the unexported errorString. runtime never itself casts errorString to runtime.Error — its plain error casts recorded only errorString → error, never errorString → runtime.Error, which errorString satisfies STRUCTURALLY through its value-receiver RuntimeError() method — and math/bits cannot name the unexported errorString, so no site ever recorded the pair and err.(runtime.Error) returned ok = false, NRE-ing on e.Error(). visitInterfaceType now, at each non-lifted non-constraint named-interface DECLARATION, records a GoImplement for every SAME-PACKAGE concrete type whose method set structurally satisfies the interface (recordLocalConcreteImplementers), sharing the value-then-pointer recordIfImplements probe with the assertion-site recorder. The producer runs in the concrete’s HOME assembly — exactly where the go2cs-gen adapter must be generated — so the fix needs zero generator/golib change; errorString records against ΔError in runtime’s own package_info.cs and math/bits resolves through it. The scan is deliberately EAGER: same-package, non-interface, non-generic concrete types, structural (no cast required), with NO gate on the assertion actually occurring and NO gate on the concrete being exported (errorString is unexported yet escapes via the exported panic value). It COMPLEMENTS the assertion-site recorder (commit fcfe4a948) — which still covers cross-package concrete dynamic types the declaration-site scan cannot see (an external p_test assertion, or an imported dynamic type).

Measurement (an isolated A/B full-stdlib reconvert) put the blast radius at just 102 net-new GoImplement records, 7.1 % over a 1442-record base — no VOLUME gate was needed. Most of the gross additions are a CLEANUP, not new surface: a concrete→interface pair a downstream consumer used to record redundantly at its own cast site (go/parser held 49 *ast.Ident/… → ast.Expr/Stmt/Decl records) now lives once in the producing package (go/ast, +96), and consumers drop the duplicate through the existing importedValueImplements dedup — the record migrates to the assembly where the adapter belongs.

One narrow SHAPE gate WAS forced, by the build rather than by volume: recordIfImplements skips a pair whenever the go2cs-gen adapter could not FORWARD one of the interface’s methods (adapterCannotForward). The generator names a forwarding target in exactly one step — this.M() for a method whose receiver is the type itself, or this.Field.M() for one reachable through a SINGLE embedded field (its receiver is that field’s own type). Two shapes fall outside that and compile to an adapter referencing a non-existent member:

The gate lives in the shared helper so both recorders honour it, and it only ever SHRINKS the recorded set — it drops zero pre-existing records (the compiling corpus is green without any of these shapes), so the corpus stays a strict subset of what already compiled: no new CS1929 or CS1503. This unblocked math/bits’ TestDiv*Panic{Overflow,Zero} assertions (5 of 6 validate end-to-end through the reconstructed adapter; the 6th, TestDiv32PanicZero, is gated by an unrelated golib gap — a hardware DivideByZeroException is not re-presented as a Go runtime.Error, and Div32 alone relies on the implicit division panic rather than an explicit panic(divideError)). (Guarded by the OptionalInterfaceStructuralAssertion behavioral test — a widget that structurally implements a narrower Tagger interface, never cast to it, held as any through a []any and asserted to Tagger; the any-typed operand makes the assertion-site recorder record nothing, so the declaration-site producer is the SOLE source of the widget → Tagger adapter, output-compared vs go run.)

A second gate is a NAME collision, resolved at write time. The adapterCannotForward shape gate was measured against a corpus build that reported only the long-standing reflect/value.cs .Clone blocker — but MSBuild SKIPS a failed project’s dependents, and flag and compress/zlib both reach reflect through fmt, so neither was ever built. With .Clone fixed, compress/zlib surfaced CS0102 + CS0111 ×5: go2cs-gen composes an adapter class name from the LAST DOT SEGMENT of each side (the same naming adapterTypeRef/valueAdapterTypeRef emit at cast sites), so zlib’s OWN Resetter and the compress/flate Resetter that its *reader also implements both compose readerжResetter in zlib_package — two .g.cs files declaring one class. The FORM is part of the name (the ж pointer prefix vs the ᴠ value infix), so only same-form pairs collide.

The loser must be chosen by ORIGIN, not by record order: writePackageInfoFile now tracks, per recorded pair, whether EVERY producer was the declaration-site structural recorder (structuralOnlyImplementations; a DEMANDED record from any emitted cast/assertion site wins permanently), and its collision prune drops a structural-only pair whose adapter name a demanded pair already owns. That is the safe direction: no emitted C# names a structural-only pair’s adapter — it exists solely so a run-time assertion can resolve — whereas dropping a DEMANDED pair strands a real cast site on a class the generator never emits (CS0246). Two colliding structural-only pairs are broken by keeping the lexicographically first, so the outcome is deterministic regardless of map iteration order. A pair the ALIAS dedup will skip (the qualified duplicate of a record already carried under a package type alias) is excluded from ownership — CrossPkgUser’s type Tagged = CrossPkgLib.Labeled records badge under both names, and the qualified one would otherwise evict a local Labeled pair while itself emitting badgeᴠTagged. Corpus-wide the prune removes exactly one record. (Guarded by CrossPkgUser: *dial satisfies both the local Labeled and the foreign CrossPkgLib.Labeled and is cast to the foreign one, so the two pairs compose one dialжLabeled; without the prune the build fails CS0102: 'main_package' already contains a definition for 'dialжLabeled'.)

RETIRED (2026-07-25) — the two recorders above, plus the test-package scan, are GONE; a named-interface assert now resolves at RUN TIME. Everything from “An assertion to a NAMED interface must record…” through the ORIGIN paragraph is HISTORY, kept because it explains records that still exist. Its founding premise — that TryTypeAssert can only resolve a named target through a compile-time GoImplement adapter — stopped being true when the tiered interface shells landed (see Every eligible interface carries runtime duck-typing shells): golib’s AdapterBinder now constructs a wrapper for a structurally-matching dynamic value from the interface’s OWN assembly, so the three STRUCTURAL producers — recordAssertConcreteImplementers (assertion site), recordLocalConcreteImplementers (declaration site) and recordTestPackageImplementers (-tests type-side scan) — were deleted along with recordIfImplements, adapterCannotForward and the structuralOnlyImplementations collision prune they existed to arbitrate (~495 lines). They were never sound, only useful: each GUESSED an assertion’s dynamic type by enumerating types it could name, and was blind by construction to a dynamic type in a later-converted assembly (io/fs recorded subFS, never os.dirFS). What REMAINS is every DEMANDED record — an explicit conversion, a var _ I = T{} witness, a resolved-concrete empty-source assert — plus the Promoted and ConstraintProxy records; ImplementGenerator is untouched, so a declared conversion still gets its nominal adapter, still the ~1.1 ns fast path. The corpus effect, measured on a seeded 305-package reconvert: 1535 → 1324 records, −335 / +124 across 53 packages. Of the 335 dropped, 73 RELOCATE — the pair moves from the provider package to each consumer that demands it, so the emitted reference changes shape (io.SectionReaderжReader → io_SectionReaderжReader, and (Scored)(Verdict)4 → new CrossPkgLib_VerdictᴠScored(…)) — and 262 are simply gone; a scan of all 1919 corpus .cs finds 261 of those 262 named by no emitted C# whatsoever, which is the direct measurement of how speculative they were. Of the 124 added, 117 are the consumers’ half of those relocations and 7 are base-interface records the interface-inheritance prune had been suppressing under a now-deleted derived-interface record (flag.textValue → Value, net.UnknownNetworkError → error, runtime.errorString → error, …). (Guarded by five EXISTING behavioral tests that now carry ZERO nominal records for the pairs they assert — DerivedInterfaceStructuralProbe, OptionalInterfaceStructuralAssertion, InterfaceToInterfaceAssertion, AnonIfaceThroughPointerAdapter, IfaceToIfaceNarrow — so their output comparison IS the shell-resolution proof; and operationally by the banked suites, testing/quick 8/8 being the direct one, since its myStruct → Generator record was the test-package scan’s sole consumer.)

A local named-FUNC value record is exempt from the interface-inheritance prune. Independently, flag failed CS0246 on boolFuncValueᴠValue — a PRE-EXISTING defect the same masking hid, reproducible with the structural recorder reverted. A C# delegate cannot be a partial struct, so a GoImplement pair whose concrete is a named func type generates a per-interface ᴠ ADAPTER CLASS rather than an entry folded into the type’s own base list. flag’s boolFuncValue is recorded against both boolFlag and Value, and boolFlag EMBEDS Value, so the subsumption prune dropped the Value pair as “covered by inheritance” — true for a partial struct, false for an adapter class, which is per-exact-interface. The ж-pointer form and the foreign-value form were already exempt (adapterClassImplementations); the local named-func value form now registers there too, so flag.cs’s new boolFuncValueᴠValue(…) keeps its record. This is the same reasoning that exempts new net_ConnᴠWriter(…), applied to the one adapter-class shape the list had missed.

The run-time structural match for an ANONYMOUS interface is SIGNATURE-aware, not name-only. (Ladder detail SUPERSEDED — ᴛAs was retired 2026-07-25 in favour of the duck-typing shells, and the two memoized tiers were folded into one per-interface itab cache on 2026-07-26; both are described under Every eligible interface carries runtime duck-typing shells. The signature-aware matching rule below is unchanged and current — only the tier it gates has moved.) An assertion to an anonymous (dynamically declared) interface — x.(interface{ Unwrap() []error }) — does not resolve through a compile-time GoImplement adapter; it resolves at run time in golib, where builtin.TryTypeAssert gates on Cache<TInterface>.Implements before invoking the generated ᴛAs conversion. That gate used to compare only method names, so two anonymous interfaces that share a name but differ in signature — errors.Is’s emitted is_typeᴛ1 { error Unwrap(); } and is_typeᴛ2 { slice<error> Unwrap(); } — were CONFLATED: a value whose Unwrap returns []error matched both, and constructing the wrong adapter (error Unwrap() bound to a []error-returning method) threw NotImplementedException from the adapter’s static constructor. The check now matches each interface method by NAME and SIGNATURE (parameter and return types), scoped to the value’s actual Go method set: TypeExtensions.StructurallyImplements resolves the value’s receiver element (a pointer box ж<X> exposes X’s value- and pointer-receiver methods; a plain value X exposes only its value-receiver methods) and requires, for every interface method, an extension method with the same name whose signature matches (the candidate’s first parameter is the receiver, which the interface method lacks). This also fixes a second defect on the SAME path: GetExtensionMethodNames collapses a closed ж<X> to the open ж<> generic definition (correct for MinBy-precedence single dispatch, wrong for a name-set membership test), so the old check admitted the pointer-receiver methods of every type — e.g. ж<errorString>, whose errorString has no Unwrap, matched is_typeᴛ1 because fmt’s *wrapError.Unwrap was in the collapsed set. Open-generic receiver methods (methods on a Go generic type, whose signature carries type parameters that cannot be compared against a concrete interface signature) keep the prior name-only match. On the same fix, error._<T>() (the single-value assertion err.(T) on an error) now routes an INTERFACE target T — including a dyn anonymous interface — through this general machinery instead of casting the carrier to error<T> (which threw InvalidCastException when the dynamic value was a generated pointer/interface adapter rather than an error<T>), first unwrapping a pointer-sourced IжAdapter to its receiver box so the structural probe sees the dynamic *T. This unblocked errors.Join/TestJoin end-to-end and made errors.Is route wrapped/multi errors to the correct Unwrap arm. (Guarded by the AnonInterfaceSignatureAssert behavioral test — a value whose Unwrap returns []error and one whose Unwrap returns error, each asserted against BOTH interface{ Unwrap() error } and interface{ Unwrap() []error }; the correct shape matches and dispatches, the wrong one misses. Output-compared vs go run; the pre-fix golib crashes the C# process at the conflated assertion.)

An interface that EMBEDS another interface must collect only the base’s INSTANCE, ORDINARY members — reflection’s default BindingFlags leak the base’s STATICS. Go’s interface{ error; Temporary() bool } (net.Error’s shape) converts to a [GoType("dyn")] partial interface classify_type : error, so both halves of the duck-typing machinery have to walk the C# base interface to see Error(). Both walks collected too much. golib’s TypeExtensions.GetInterfaceMethods called baseInterface.GetMethods() with default flags (Public | Instance | Static) where the direct-member call above it correctly passes BindingFlags.Public | BindingFlags.Instance — and golib’s hand-written core interfaces expose static duck-typing conversion helpers (error.As<T>, fmt.Stringer.As<T>), while TypeGenerator stamps ᴛAs onto every dyn interface. So StructurallyImplements demanded a static As from the dynamic value’s Go method set, which no Go type can ever satisfy: every embedding interface’s structural probe answered FALSE, and an assert against a value that plainly has both methods returned a MISS. Symmetrically, go2cs-gen’s InterfaceDeclarationSyntaxExtensions.GetInterfaceMethods walked AllInterfaces members with no IsStatic / MethodKind filter, so the Δ wrapper FORWARDED those statics — the two surviving As overloads emitted duplicate AsByPtr/AsByVal delegate types and s_AsByPtr/s_AsByVal fields (CS0102 ×6, so the project did not even compile). Both walks now filter to instance, ordinary members; property/event accessors and constructors are likewise never Go interface methods. Note the base walk must stay TRANSITIVE — .NET’s Type.GetInterfaces() already flattens an inheritance chain, so a 3-deep interface{ named; Depth() int } → named : stringish collects all three levels. (Guarded by the DerivedInterfaceStructuralProbe behavioral test: an anonymous interface embedding error asserted against a *tempErr that has Error+Temporary and a *plainErr that has only Error, plus a 3-deep embedding chain with a deepest-level negative control, output-compared vs go run. Pre-fix the project fails to compile; with only the gen half fixed it compiles and prints not temporary where Go prints temporary=true msg=boom.)

A typed-error assert (err.(*T) / err.(T)) resolves through the SAME machinery — the error<T> carrier is only a special case. errorExtensions._<T>(this error) is the overload C# picks whenever the assert operand is statically an error (builtin._<T>(this object) is less specific), and its body used to be a direct cast, ((error<T>)target).Target. That only ever matched golib’s own reflective carrier — the object error.As builds — which converted code never constructs: an error produced from a Go pointer is a generated IжAdapter (new fs.PathErrorжerror(Ꮡ(new PathError{…}))), and one produced from a value is the implementing struct itself. So every single-value typed-error assert against real converted code threw InvalidCastException (“PathErrorжerror to error<ж<fs.PathError>>”) — and because that is a raw CLR fault rather than a PanicException, Go’s recover could not even see it. os’s dirFS.Open path-fixup (err.(*PathError).Path = name) died on it, taking io/fs’s TestGlob, TestReadDirPath and TestReadFilePath down as infrastructure errors. The comma-ok form was never affected: err._<T>(ᐧ) has no errorExtensions overload and already bound to builtin’s adapter-aware TryTypeAssert. _<T> now unwraps the error<T> carrier when the dynamic value actually IS one, and otherwise defers to ((object)target)._<T>() — the one type-assertion machinery — so a statically-error operand and an any-typed one can never disagree about the same assert. Commit cb0f58078 (above) closed the INTERFACE half of this defect; this closes the CONCRETE-type half, and the two runtime-Type overloads (err._(type), err._(type, out result)) were routed through builtin.TryTypeAssert(object, Type, out object) for the same reason, replacing a reflection lookup of error<>’s explicit conversion operator that had the identical carrier-only blind spot. (Guarded by the TypedErrorAssertThroughAdapter behavioral test — pointer- and value-sourced errors asserted back to their concrete types through an error boundary, a write through the asserted *T observed on the interface value, comma-ok misses in both directions, and a failed single-value assert proven to be a recoverable panic; output-compared vs go run, and the pre-fix golib crashes the C# process with exit code 2.)

Constructing the run-time duck-typing wrapper is FAIL-SOFT — a structural false positive must MISS, not crash. golib’s structural probe is deliberately imprecise in one place: for an open-generic receiver method it matches by NAME ONLY, because the candidate’s signature carries the receiver’s type parameters and cannot be compared against the interface’s concrete signature (see StructurallyImplements’ remarks). So a Go generic’s Get() T matches interface{ Get() string } for every instantiation — box[int] included, where Go plainly says no. The probe therefore said yes, builtin.TryTypeAssert closed the generated ᴛAs over box<int> and invoked it, the wrapper’s static initializer found no bindable extension overload and threw NotImplementedException, and reflection re-wrapped that as TargetInvocationException — which escaped the assertion as a process crash (Exception has been thrown by the target of an invocation., exit 2) where go run prints a clean comma-ok miss. A MISS is normal control flow at every emitted assertion and type-switch site (the method’s own remarks say so), so wrapper construction now runs through one fail-soft helper (TryConstructInterfaceWrapper) covering both close-over paths — by-value and by-pointer over a receiver box — and any construction failure answers ok=false: a MakeGenericMethod constraint violation, an already-faulted type initializer, a conversion whose result is not the asserted interface (the hard (T) cast became an is T pattern), or missing dynamic-code support. This is the same discipline golib’s CreateInterfaceHandler already applies. It narrows nothing: a genuine duck-typed assertion still resolves and dispatches. (Guarded by the StructuralAssertFailSoftMiss behavioral test — a real match then a false-positive probe, on each close-over path; pre-fix the C# process dies on the second line.) Superseded 2026-07-25 — the ᴛAs route this helper closed, TryConstructInterfaceWrapper itself, and CreateInterfaceHandler were all retired when anonymous interfaces moved onto the runtime duck-typing shells (see Every eligible interface carries runtime duck-typing shells…). The fail-soft rule is unchanged and now lives in one place, AdapterBinder.TryCreate; the guard still holds.

CLOSED 2026-07-25 (kept as the measured statement of the problem) — a NAMED interface had no run-time wrapper, so a cross-package structural satisfaction missed. Every named interface now carries runtime duck-typing shells (see Every eligible interface carries runtime duck-typing shells… below), io/fs validates 18/18, and the ᴛAs machinery described here no longer exists — anonymous interfaces use the same shells. The two recorders above (assertion-site and declaration-site) can only see the converting package’s scope, so an assert whose dynamic type comes from a package converted later — or from one the asserting package does not import at all — records nothing, and the run-time fallback cannot cover for it: builtin.TryTypeAssert’s structural path needs the generated ᴛAs/Δ<Iface><T> duck-typing wrapper, which TypeGenerator emits only for [GoType("dyn")] (anonymous) interfaces — 33 of them corpus-wide, against 267 named ones. Measured ground truth (io/fs, 2026-07-24): os.dirFS structurally satisfies fs.ReadDirFS/fs.StatFS and ж<os.File> satisfies fs.ReadDirFile, and golib’s structural PROBE agrees — builtin.Implements<fs.ReadDirFS>(dirFS) returns true for all three — yet each assert returns a MISS, because GetInterfaceConversionMethod finds no ᴛAs on the named target and bails. io/fs’s ReadDir therefore falls back to fsys.Open + file.(ReadDirFile), misses again, and returns readdir .: not implemented with zero entries; fs.Glob swallows that error (TestGlob sees an empty match set) and fs.WalkDir hands it to the callback (TestIssue51617 sees only .). Nominal recording can never be complete here — an interface’s dynamic type may live in any assembly, including one built after the asserting package — so the durable fix is to give NAMED interfaces the same run-time duck-typing wrapper dyn ones already get, with method collection made TRANSITIVE (a named interface inherits members through C# interface inheritance: ReadDirFS : FS needs Open as well as ReadDir). That is a corpus-wide go2cs-gen change and is not yet done; io/fs validates 16/18 with these two tests open.

The types that support these tuple-returns are defined in the golib library; ordinary user-code tuple returns convert as normal C# tuples without special handling.

A package-level var a, b = f() reads ValueTuple components. C# static field initializers cannot deconstruct a tuple, so the per-name field emission assigned the WHOLE result tuple to the first field (CS0029 — edwards25519’s var identity, _ = new(Point).SetBytes(…)). With exactly one non-blank name the component read is appended to the inline call (internal static ж<Point> identity = …SetBytes(…).Item1; — blank names keep their uninitialized _ᴛNʗ fields, and the call still runs once). With two or more non-blank names the call is evaluated ONCE into a hidden tuple field and each name reads its component (internal static (nint, @string) tupleᴛ1ʗ = pair(); internal static nint n = tupleᴛ1ʗ.Item1; — C# static initializers run in textual order, so the reads follow the temp). Gated to package scope, no explicit type, one call initializer typed as a tuple; in-function var x, y = f() keeps the existing path. (Guarded by the GlobalTupleVarDecl behavioral test — both shapes plus a call-count probe proving single evaluation, output-compared vs Go.)

Every trailing argument of a variadic pointer parameter gets the box treatment. The per-parameter argument loop visits declared parameters only, so checkInitialized(p, q) binding two deref-aliased pointer parameters to ...*Point boxed only the first (checkInitialized(Ꮡp, q) — CS1503). The pointer-argument box treatment now fans out from the variadic parameter’s index to every trailing argument, mirroring the type-parameter @string fan-out; the spread form (f(s…)) is excluded as before, and non-variadic calls are byte-identical. (Guarded by the VariadicPointerParam extension pairTotal — three deref-aliased pointer params forwarded to the variadic, value vs Go.)

A call-result delegate of a NAMED func type must resolve its signature through Underlying(). All the per-argument treatments above (pointer boxing, interface conversion, u8 suppression) are driven by getFunctionSignature, which for a callee that is itself a call — valueEncoder(v)(e, v, opts), encoding/json — read info.TypeOf(fun).(*types.Signature). When the inner function returns a named methodless func type (valueEncoder returns encoderFunc), info.TypeOf is a *types.Named, so that assertion failed and the signature came back nil — the per-argument loop never ran, and the pointer receiver e (a deref’d ref var e = ref Ꮡe.Value) passed its value alias where the ж<encodeState> slot wanted the box Ꮡe (CS1503). The *ast.CallExpr arm now asserts on Underlying(), looking through the named func type to its signature (a no-op when the result is already an unnamed signature). Byte-identical across the behavioral corpus and across an A/B of encoding/json+gob+text/template+net/http+reflect — a single line moves (json’s valueEncoder(v)(e,…) → (Ꮡe,…)). (Guarded by the NamedFuncResultPointerArg behavioral test — adder() returning a named addFunc called immediately with a *State receiver that must box, mutation through the box observed vs Go.)

A variadic closure rebinds its params array to a slice at the top of its body. A variadic parameter a ...T arrives in C# as a params ꓸꓸꓸT array named <name>ʗp (a distinct name, so it doesn’t collide with the slice the body expects); the body then references the bare <name> as a slice<T>. A top-level function emits a var <name> = <name>ʗp.slice(); prologue as its first block statement, but a function literal emitted no such prologue, so any closure that referenced its variadic parameter used an undefined bare name (CS0103 — internal/dag’s errorf := func(format string, a ...any) { … fmt.Sprintf(format, a...) } spread a… against a name that was never declared). A function literal now emits the same rebinding prologue. Because the prologue is prepended before the single-return→expression-body collapse, a variadic closure whose body is a lone return f(a...) keeps its block form (the rebound name is a statement-scoped local) rather than collapsing to an undefined expression. An IIFE literal is excluded — it emits parameter names only (the raw a, with the delegate cast supplying the params type), so there is no <name>ʗp array to .slice(). (Guarded by the VariadicClosureSpread behavioral test — a single-return closure spreading a... into fmt.Sprintf, a closure ranging its variadic slice, and a single-return closure forwarding a... to another variadic; output-compared vs Go.)

An UNNAMED or BLANK variadic parameter emits no rebinding at all. Go permits a variadic parameter with no name (func cmdPipeTest(...string)) or with the blank name (func f(_ ...int)); either spelling leaves the parameter unreferenceable from the body, so the rebound slice<T> local is dead by construction. Emitting it anyway was broken rather than merely redundant. An unnamed parameter named that local with the EMPTY string — var = ʗp.slice();, which the C# parser reads as an assignment to a nonexistent var (three CS0103 in os/exec’s converted test sources: cmdPipeTest, cmdStdinClose, cmdStderrFail, the wall that held that package in front of the TestMain flag bridge) — and inside a function literal it was doubly wrong: the literal’s signature builder normalizes the absent name to _ and declares params ꓸꓸꓸnint _ʗp, while the prologue kept rendering ʗp from the raw name, so the dead local carried both an empty name and a name the signature never declared. A blank parameter emitted var _ = _ʗp.slice();, which compiles but declares a REAL local named _ (a plain var _ = e; declaration is a variable, not a discard) that then hijacks every _ = … discard the body writes — the same CS0029 class bodyUsesBlankDiscard exists to prevent for a blank parameter name. Both spellings now emit no rebinding; the signature is untouched, keeping the params array under its own ʗp name and simply leaving it unread, exactly as the Go parameter does. This is the same ruling, for the same reason, that an unnamed/blank pointer parameter’s deref alias already takes (it would otherwise emit ref var = ref Ꮡ.Value;). A named variadic still rebinds — the skip is scoped to the two unreferenceable spellings, not to variadic parameters at large. (Guarded by the UnnamedParams behavioral test, which pins all three shapes — unnamed, blank, and a named control that IS read — at declaration, method and function-literal positions, output-compared vs Go.) Stdlib footprint: zero. An AST census of GOROOT finds exactly one production site, syscall/syscall_linux.go’s func cgocaller(unsafe.Pointer, ...uintptr) uintptr, and it is bodyless (a //go:uintptrescapes linkname target, emitted internal static partial uintptr cgocaller(@unsafe.Pointer _Δp0, params ꓸꓸꓸuintptr ʗp);) so it has no prologue to skip on any target; the other four sites are all in os/exec’s test sources.

A non-escaping variadic parameter binds through the stack-only sslice<T> view. The C# signature already receives the arguments as params Span<T>, so a prologue whose uses are proven frame-local now emits var xs = xsʗp.sslice(); instead of xsʗp.slice(). The old Span<T>.slice() path calls ToArray(); the stack view removes that allocation and, for a spread call f(s...), keeps the callee aliased to the caller’s backing array as Go requires. The proof is intentionally narrow: direct len/cap, element indexing, and range are eligible. Passing or spreading the slice, assigning or returning it, append, address-taking, slicing, interface boxing, channel/go use, or a nested function literal falls back to the heap slice<T>. When defer/recover requires an execution wrapper, the converter passes the incoming params Span<T> by ref through the one-reference func overload (whose generic reference slot uses C# 13’s allows ref struct anti-constraint) and performs the slice rebinding inside that wrapper. The wrapper therefore does not capture the outer ref-like parameter, and an otherwise-eligible body keeps .sslice() plus Go spread aliasing. Uses that genuinely require a heap slice still fall back to .slice(). Function literals use the same eligibility map and ref-wrapper path. Golib’s cap<T>(in sslice<T>) complements the existing len overload; append deliberately has no sslice grow path, so any append remains a heap case. (Guarded by VariadicPointerParam: safe range/len/cap/index sites emit .sslice(), a spread element replacement is observed through the caller’s slice against go run, defer-wrapper declarations and closures pass the params Span by ref, preserve spread element writes, and nested captures still pin the .slice() fallback.)

A pack that is only COPIED FROM or FORWARDED takes the view too (2026-09-24, REC-C §A). The paragraph above predates two widenings of the same proof, recorded in DESIGN-slice-idiom-allocations.md §A. Copy source: copy(dst, xs) reads the pack and binds golib’s copy(…, in sslice<T>). Pass-through: f(x, xs...) spreads the pack into another variadic (f(x, xs.ꓸꓸꓸ), the view’s own Span<T>), and builtin append(dst, xs...) binds golib’s appendꓸꓸꓸ(…, in sslice<T>). Neither can let the pack outlive the frame: the callee receives a params Span<T> C# will not let it retain, and a callee that keeps its pack copies it in its own prologue. The forward is also a SEMANTICS fix. Go’s f(xs...) passes the slice itself, so a callee that writes an element writes the caller’s storage, and the copy the forwarding function used to make hid that write. Still refused: append INTO the pack, the pack as copy’s DESTINATION, a spread from a defer or go statement (whose arguments are captured), and every use the paragraph above lists. Exact overloads are load-bearing for the ALLOCATION as well as for binding: without them the ISlice<T> forms still bind, through sslice’s implicit conversion to slice<T>, which copies. Stdlib footprint at fa18863b94: 161 prologues on windows and 158 on linux and darwin, each a one-line .slice() -> .sslice() swap. Behavioral: 8 goldens, the same swap. (Guarded by the VariadicPackPassThrough behavioral test, whose forward case diverges from go run on the old emission; by the converter’s TestVariadicPackViewAdmitsOnlyNonRetainingUses, controlled both ways; and by GolibTests’ SSliceSpreadTests.)

The ꓸꓸꓸT alias identifier mirrors the GO name; its referent must be using-independent. The readable params ꓸꓸꓸT form is a namespace-scope C# using alias (using ꓸꓸꓸstring = Span<@string>;), so it applies only where a legal alias identifier exists — everything else falls back to the inline params Span<T>. Two separate constraints decide that, and only the first is about the name. (1) The identifier cannot contain <, > or ., so it transliterates the element’s Go name, joining any package qualifier with TypeAliasDot — Go’s ps ...unsafe.Pointer becomes params ꓸꓸꓸunsafeꓸPointer psʗp, the same pkgꓸType convention package_info.cs already uses for its global usings. Go names are preferred over the emitted C# ones: they need no @ keyword-escape stripping (@ is legal only at identifier start — ꓸꓸꓸ@string is a lex error, CS1002/CS0116 — while the Span<> referent keeps the escape), carry no _package class suffix, and undo a Δ collision-rename, so go/types’ ...Type reads ꓸꓸꓸType rather than ꓸꓸꓸΔType. Any Go identifier is a legal C# one and the ellipsis prefix defuses keywords (...event → ꓸꓸꓸevent); types with no Go name — a basic type, a universe type (error), a lifted anonymous struct (internal/fuzz’s CorpusEntry) — transliterate the emitted C# name instead. Because only the name is constrained, a qualified referent still reads like Go: a same-package element is qualified with the package class (a bare nested name like statDep does not resolve at namespace scope, CS0246) yet still emits params ꓸꓸꓸShape shapesʗp over using ꓸꓸꓸShape = Span<main_package.Shape>;. (2) The referent is resolved by C# with the compilation unit’s own using directives not in effect, so it may not name another file-local alias. A cross-package element renders in the short alias form (@unsafe.Pointer, ast.Expr), which is exactly that — left as-is it fails CS0246, and go2cs-gen (which copies these usings into its generated files) cannot resolve the symbol either and falls back to unescaped text, Span<unsafe.Pointer>, whose bare keyword cascades to CS8956. Such a referent is therefore rewritten to the alias’s own target — Span<unsafe_package.Pointer>, Span<global::go.go.ast_package.Expr> — which is using-independent by construction, being what the using <alias> = <target>; line itself resolves. The mapping is recorded where the import using is emitted rather than re-derived, so the -tests package-under-test rebinding is honored automatically; an alias not yet bound when the declaration is visited (visitFile synthesizes canonical aliases for inference-only foreign references after the walk) degrades to the inline form. A pointer element is the one constructed form that does transliterate: go2cs already writes *T as ж<T>, so bs ...*box reads params ꓸꓸꓸжbox bsʗp. Its pointee resolves through the same routine and so takes the same namespace-scope qualification — the alias referent says Span<ж<main_package.box>> where the inline form could say bare ж<box>, since only the inline form sits inside the package class — and the two rules compose, giving go/doc’s ...*ast.File a ꓸꓸꓸжastꓸFile = Span<ж<global::go.go.ast_package.File>>. A pointee with no alias form of its own (a type parameter, a constructed pointee such as *[]byte) takes the whole element inline with it. Genuinely inline-only, then: a type parameter (never in scope inside a referent — First<T>(params Span<T> valsʗp)) and every other constructed element, for which no established transliteration exists (map<@string, any>, slice<byte>, Action<ж<options>>). Two element types transliterating to one identifier in a single file would bind it twice (CS1537), so the first claim wins and the loser stays inline. Deriving from Go names is also what keeps os/signal’s long-standing using ꓸꓸꓸosꓸSignal = Span<osꓸSignal>; and text/template’s ꓸꓸꓸreflectꓸValue byte-identical through this path — they already followed the convention, because their bare C# names come from global usings in package_info.cs, which do carry over into a using-alias referent. (Guarded by the VariadicSlotInterfaces behavioral test — a same-package interface element — and by VariadicPointerParam, which pins all three outcomes in one file: an aliased pointer (ꓸꓸꓸжbox), an aliased cross-package element (ꓸꓸꓸunsafeꓸPointer), and an inline constructed one (Span<slice<byte>>); the type-parameter arm of that fallback is held by GenericVariadicFunc. All output-compared vs Go. Stdlib footprint: every previously-inline site becomes an alias except the type-parameter and non-pointer-constructed ones — same-package elements read bare (ꓸꓸꓸAttr, ꓸꓸꓸtraceArg, ꓸꓸꓸWriter, ꓸꓸꓸType), cross-package ones carry their qualifier (ꓸꓸꓸunsafeꓸPointer in runtime, ꓸꓸꓸastꓸExpr in go/types), and pointers take the ж form — unicode.In(r, ranges ...*RangeTable) becomes params ꓸꓸꓸжRangeTable rangesʗp.)

A named result is DECLARED only when something reads it (2026-08-08). Go’s named results are ordinary addressable locals, so the converter declared every one of them at function entry. But Go names results for documentation far more often than it uses them — func EncodeRune(r rune) (r1, r2 rune) never mentions r1/r2 again — and the emitted rune r1 = default!; is then dead on arrival. That one shape was 1,218 of the corpus’s 1,219 CS0219 (“assigned but its value is never used”) warnings. Nothing is lost by omitting it, because the names survive exactly where a reader reads them — on the C# tuple return type — so the emission moves closer to the Go source, not further from it:

// before
public static (rune r1, rune r2) EncodeRune(rune r) {
    rune r1 = default!;
    rune r2 = default!;

    if (r < surrSelf || r > maxRune) {

// after — the names still read off the signature
public static (rune r1, rune r2) EncodeRune(rune r) {
    if (r < surrSelf || r > maxRune) {

The declaration is kept whenever anything can read it, and the check is deliberately conservative in every unclear case — a retained dead declaration costs one line, a dropped live one is CS0103. It stays when the body references the result (read, assigned, address-taken, or captured by a closure — a capture is a use, so that walk descends into function literals); when the body has a naked return, which reads every named result by definition (that walk stops at a nested *ast.FuncLit, whose bare returns belong to the literal — the iter.Pull shape above depends on this); when the result is heap-box backed, whose box is the storage the render sites reference; and when the function is lowered through either defer form — namedReturnDeferMode, where the declarations sit outside the func() wrapper precisely so deferred closures can mutate them, or a GoFrame, whose named exit emits a trailing return <names>; after the try. In those last two the generated code reads the locals and the Go body need never mention them, so liveness is switched off wholesale rather than inferred. The same rule and the same opt-out apply to function literals (namedReturnDeclLines). Keeping the check rather than suppressing the code corpus-wide also preserves CS0219 as a live signal: a genuinely dropped assignment to a named result still surfaces. (Guarded by namedResultLiveness_test.go, which pins one function per liveness reason plus the dead shape and the outer-dead/inner-naked case, and is proved in BOTH directions by negative control. Corpus effect: CS0219 1,219 → 52, none of them a named-return prologue — 33 are in hand-owned files the converter never re-emits, 18 are Go’s own var witnesses for a constant-folded unsafe.Sizeof/Offsetof, and 1 is a folded local const.)

A grouped var spec with one multi-result call deconstructs

A grouped var (name, offset, abs = t.locabs() ...) spec is not a :=, so the assignment tuple machinery never saw it – the per-name path assigned the WHOLE result tuple to the first name and silently DEFAULTED the rest (time appendFormat read a zero abs; a silent-wrongness class beyond the CS0029 that exposed it). Function-local specs now emit the C# tuple deconstruction, matching the := form; package-level specs use the once-evaluated hidden-field component reads:

var (ln, ls) = pair();

Guarded by GlobalTupleVarDecl (both levels, with a call-count check proving single evaluation).

The function-local gate asks whether a name has a BOX, not whether it “escapes” (2026-07-31). That branch is gated to specs no name of which needs a ref heap<T> box declaration, and it read the raw identEscapesHeap flag — which the escape analysis blanket-sets for every inherently heap-allocated local (pointer, slice, map, chan, interface, func), because those are already references and get no box unless their address is genuinely taken. So the gate rejected specs that are entirely plain, and every tuple with an interface or func result fell back to the very per-name path this branch exists to replace:

context.Context ctx = context.WithCancel(context.Background());   // the WHOLE tuple  (CS0029)
Action cancel = default!;                                          // silently defaulted

identHasHeapBox is the predicate that answers the gate’s actual question, and it is what the branch now calls. This is the trap paramAddressTakenNeedsBox already documents from the other side — a verdict the box gate then refuses leaves identEscapesHeap set with no box behind it — and it stayed hidden because (int, string)-shaped tuples, the ones anyone reaches for when probing, work fine. net’s var ctx, cancel = context.WithCancel(context.Background()) is the corpus site. (Guarded by the GlobalTupleVarDecl extension — a local var si, fi = ifaceAndFunc() returning an interface and a func, both read back.)


← Short Variable Redeclaration (Shadowing) · Index · Slices and Arrays →