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 resolves at RUN TIME. h.(encoding.BinaryMarshaler) — where h is a hash.Hash32 whose dynamic type is *digest — needs no compile-time record: golib’s AdapterBinder builds a wrapper for a structurally matching dynamic value from the interface’s OWN assembly (see Every eligible interface carries runtime duck-typing shells). That covers a dynamic type the asserting package cannot name — math/bits asserting err.(runtime.Error) on runtime’s unexported errorString — and one from a package converted later (io/fs asserting fs.ReadDirFS on os.dirFS). The GoImplement records that remain are the DEMANDED ones — an explicit conversion, a var _ I = T{} witness, a resolved-concrete empty-source assert — plus the Promoted and ConstraintProxy records; ImplementGenerator still emits a nominal adapter for each, the fast path for a declared conversion.

An earlier design recorded STRUCTURAL implementers at compile time instead — at each assertion site (recordAssertConcreteImplementers), at each named-interface declaration (recordLocalConcreteImplementers) and in the -tests type-side scan (recordTestPackageImplementers), with a shape gate and a name-collision prune between them. It was retired because each recorder guessed an assertion’s dynamic type by enumerating the types it could name, and so was blind by construction to a dynamic type in a later-converted assembly. (Guarded by behavioral tests that carry ZERO nominal records for the pairs they assert — DerivedInterfaceStructuralProbe, OptionalInterfaceStructuralAssertion, InterfaceToInterfaceAssertion, AnonIfaceThroughPointerAdapter, IfaceToIfaceNarrow — so their output comparison IS the shell-resolution proof.)

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. (The tiers this rule gates are described under Every eligible interface carries runtime duck-typing shells: one per-interface itab cache, and a shell AdapterBinder builds. The signature-aware matching rule below is current.) 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 Cache<TInterface>.Implements decides whether the value implements the interface before a shell is built. A gate that compared only method names would conflate shapes, 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(); } — would be CONFLATED: a value whose Unwrap returns []error would match both, and the wrong adapter (error Unwrap() bound to a []error-returning method) could not be built. The check 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 partial interface classify_type /*dyn*/ : 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 error interface exposes static conversion helpers (error.As<T>). 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 target._<error, T>() (golib’s builtin._<TSource, T>, which runs the same TryTypeAssert as _<T>(this object) and names error as the source type in the panic text) — 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 — 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 lives in one place, AdapterBinder.TryCreate; the guard still holds.

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 a per-name field emission would assign the WHOLE result tuple to the first field (CS0029 — edwards25519’s var identity, _ = new(Point).SetBytes(…) exposed it). With exactly one non-blank name the component read is appended to the inline call (internal static ж<box> defaultBox = makeBox(7).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. A spec whose call must wait for vars declared later or in another file — edwards25519’s among them — is relocated as one unit into an initᴛ method instead. (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 rule, 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.) In the standard library the one production site, syscall/syscall_linux.go’s func cgocaller(unsafe.Pointer, ...uintptr) uintptr, 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 sites are 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 emits var xs = xsʗp.sslice(); instead of xsʗp.slice(). Span<T>.slice() calls ToArray(); the stack view avoids 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>. A function that defers or recovers runs its body inline in the try of its GoFrame (see Defer / Panic / Recover), so the slice rebinding is the first statement inside that try. No delegate captures the ref-like params 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 the same frame. 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, deferring declarations rebind .sslice() inside their GoFrame and 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. Two widenings of the same proof are: 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 a copy made by the forwarding function would hide 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. In the standard library and the behavioral goldens each such site is a one-line .slice() -> .sslice() 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. 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 nearly every CS0219 (“assigned but its value is never used”) warning in the corpus. 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 defers or recovers, which lowers it to a GoFrame (namedReturnDeferMode): the declarations sit before the try precisely so deferred closures can mutate them, and the frame’s named exit emits a trailing return <names>; after the try. There 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. The CS0219 warnings left in the corpus are not named-return prologues: they are in hand-owned files the converter never re-emits, Go’s own var witnesses for a constant-folded unsafe.Sizeof/Offsetof, and 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 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”. 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 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 →