Conversion Strategies

A high-level, example-driven tour of how go2cs maps each Go construct to C#. This is the readable overview – every section ends with a Reference → link into the exhaustive ConversionStrategies-Reference.md, where the same topic is documented in full: every emitted form, edge case, phase-level fix, and the behavioral test that guards it. Read the summary for the shape; open the reference for the why.

The guiding goal is that the generated C# is both behaviorally and visually similar to the original Go, so a Go developer can read the output and follow it. Two things the visible code does not show in full make that possible: a hand-written runtime library, golib (src/core/golib/, supplying slice<T>, map<K,V>, channel<T>, @string, ж<T>, nil, the builtins, …), and a set of Roslyn source generators (src/gen/go2cs-gen/) that synthesize the Go semantics C# cannot spell directly (interface satisfaction, receiver overloads, struct-embedding promotion, named-type operators).

The C# snippets below are drawn from the actual converted standard library (src/core/, Go 1.23.1) wherever possible, paired with their original Go source. A few use small illustrative programs where that reads more clearly. Glyphs you will see throughout: ж<T> a heap “box” (pointer, read “zhe”), address-of, Δ a disambiguation rename (read “delta”), @string the Go string type, default! = nil in value position, and a handful of operator glyphs (ᐸꟷ/ꟷᐳ channel receive, goǃ goroutine, / comma-ok/type sentinels).


Contents


At a glance

Go C# rendering Machinery
package foo · top-level func Bar() partial class foo_package · static methods (receiver methods → extension methods) converter
import "x/y" using y = go.x.y_package; + a ProjectReference converter
int / uint / uintptr nint / nuint / uintptr (a distinct golib struct) BCL / golib
int32, byte, rune, float64, … same-named C# aliases (global using rune = System.Int32;) global usings
untyped constant UntypedInt / UntypedFloat / UntypedComplex wrapper golib
type Celsius float64 [GoType("num:float64")] partial struct Celsius TypeGenerator
nil nil (golib NilType) or default! in value position golib
interface{} / any any (a global alias for object) BCL
[]T slice · [N]T array slice<T> · array<T> golib
map[K]V · chan T map<K,V> · channel<T> golib
string @string (heap) · sstring (non-escaping stack view) golib
v, ok := m[k] (comma-ok) var (v, ok) = m[k, ꟷ]; golib
a, b = b, a (a, b) = (b, a); C# tuples
*T · &x ж<T> heap box · Ꮡx address-of golib
type I interface{…} [GoType] partial interface + generated implementing glue ImplementGenerator
struct embedding promoted field accessors + method forwarders TypeGenerator
func (t T) M() / func (t *T) M() [GoRecv]/this extension method + a ж<T> overload RecvGenerator
f := func(…){…}, only ever called a C# local function ret f(…) {…} — captures with no display class and no delegate converter
defer f() · panic(x) · recover() body INLINE in try/catch/finally beside a GoFrame local; defer(f, ref ᒐ); throw panic(x) golib
go f() · select {…} goǃ(…) · switch (select(…)) golib
x.(T) · switch x.(type) x._<T>() · x.type() golib / converter
generic [T Constraint] where T : <lifted interface(s)> golib / .NET

Package Conversion

A Go package becomes a C# static partial class named <pkg>_package, inside a root go namespace; the import path’s leading segments become the namespace. Go’s package-level functions are static methods; receiver methods are emitted as extension methods (decorated [GoRecv]). Using a partial class lets the functions from a package’s many files coalesce under one import. A program with main converts to an executable project; imported packages convert to referenced library projects.

import "unicode/utf8"
using utf8 = go.unicode.utf8_package;    // one alias; per-file `package_info.cs` carries the global usings

A blank import — import _ "image/png", imported purely so its init runs — emits no using (a using _ alias would hijack C#’s discard) but must still initialize. A converted init is a .NET [ModuleInitializer], which fires only at first use of something in its assembly, and a blank import names nothing — so the converter emits a hook that forces it, once per assembly, ahead of the file’s own inits.

import _ "image/png"    // registers the PNG decoder with image.Decode
// blank import: go.image.png_package (side effects only; no using emitted — a `using _` alias hijacks C# discards)
[GoInit] internal static void initᴛᴛblankImportimagepng() { builtin.initPackage(typeof(go.image.png_package)); }

A package whose emitted C# differs by platform keeps the differing files in per-GOOS subfolders, and its .csproj compiles exactly one of them — <Compile Include="$(GoTargetOS)/*.cs" />, defaulting to windows. Files identical on every platform stay flat, so this touches only the packages that genuinely vary: 37 of 307. A package whose imports also differ by platform — 21 of them, os reaching internal/syscall/windows on Windows and internal/syscall/unix elsewhere — states its common references once and selects the rest the same way:

<ItemGroup Condition="'$(GoTargetOS)'=='windows'">
  <ProjectReference Include="$(go2csPath)core/internal/syscall/windows/internal.syscall.windows.csproj" />
</ItemGroup>

A hand-owned file needs the same treatment and cannot get it from the classifier, which compares emissions and never sees a file nothing emits. Such a file belongs in exactly the platform builds its principal takes part in — <name>.cs for an <name>_impl.cs companion, the <name>.cs.auto review sibling for a [module: GoManualConversion] whole-file replacement — after which the ordinary rule applies: shared by every platform means flat, a subset means one copy per folder. So runtime/lock_sema_impl.cs lives in runtime/windows/ and runtime/darwin/ (Linux takes lock_futex.go), while os/proc_impl.cs stays flat even though proc.cs is per-GOOS, because every platform has one.

Full detail: Reference → Package Conversion — cross-package imports & assembly references, module-aware resolution, exported type aliases crossing packages (the -qualified global using round-trip), cross-package interface-satisfaction witnesses, blank-import initialization, build-tag/GOOS/GOARCH file selection, per-GOOS source folders, and the auto-generated .slnx solutions (the stdlib solution, and the -recurse per-project solutions grouped into src/pkg/core folders).


Package-Level Variable Initialization Order

Go initializes package vars in dependency order (resolved through function calls); C# static field initializers run in an undefined order across a partial class’s files. A var whose initializer depends — directly, through a package function, or via a func literal — on a var in another file (or declared later in the same file) is emitted as a bare field plus an init method beside it, and a generated package_init.cs static constructor calls those methods in Go’s InitOrder. C# runs all field initializers before any static-ctor body, so the relocated initializers always see their non-relocated dependencies ready. Everything else keeps the readable inline form.

var procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx") // modkernel32: another file
internal static ж<LazyProc> procSetFilePointerEx;
internal static void initprocSetFilePointerEx() { procSetFilePointerEx = modkernel32.NewProc("SetFilePointerEx"u8); }
// package_init.cs: static syscall_package() { …; initᴛprocSetFilePointerEx(); … }

A constant can be a dependency too, but only the two forms that stay initialized fields rather than get-only properties — a string const and a GoBigConst const. Go lists no initialization order for constants at all, so that edge is one the conversion has to add itself.

Full detail: Reference → Package-Level Variable Initialization Order — the three hazard shapes, transitive dependency analysis, moved-dependency closure, addressed globals, tuple-deconstructing specs relocated as one unit, the constant-dependency edge, and the PackageVarInitOrder / InitOrderTupleSpecs behavioral guards.


Compiled Library versus Source Code

Go compiles all source together (including the stdlib), which lets its compiler do whole-program escape analysis. The go2cs converter assumes values can escape to the heap except in the simplest-to-detect cases (see Pointers) — a safe default that can cost an unnecessary heap box, and the one that holds when converted packages are consumed as compiled libraries: the standard library is published on NuGet as go.<pkg> / go.lib / go.gen, which fits how C# developers usually consume dependencies, and a -recurse=nuget conversion references those packages directly.

Full detail: Reference → Compiled Library versus Source Code.


Constant Values

A typed Go constant emits with its concrete C# type. An untyped constant emits as a golib Untyped* wrapper, so it adapts to whatever numeric type its use site needs — just like an untyped Go constant taking its type from context. Numeric literal formatting is preserved where Go and C# overlap (hex, binary, _ separators), so bit masks and addresses stay recognizable.

const MaxRetries = 3          // typed by use
const win = 100               // untyped
const mask = 0x4000           // formatting preserved
public const nint MaxRetries = 3;
internal static UntypedInt win => 100;
internal static UntypedInt mask => 0x4000;   // not flattened to 16384

Whenever C# can say const it does. When it cannot — a [GoType] struct such as the Untyped* wrappers, a named type, uintptr, or a complex is not a legal constant type — the declaration is a get-only property, not a static readonly field. A Go constant has no initialization at all, while C# runs static field initializers in class-textual order, so as a field a constant could be read as its type’s DEFAULT by any package-level variable declared ahead of it — silently. That is how compress/flate’s Huffman decode table was allocated at length 0 instead of 512. (Two allocating forms — @string and GoBigConst — stay fields on purpose; see the reference.)

Float constant values emit exactly: the Go source literal verbatim when it is valid C#, else the shortest round-trip form — never a shortened decimal. And a function-local untyped constant whose every use resolves to one concrete type is tightened to that type — declared with C#’s const where legal, with the now-redundant per-use casts dropped (one value-changing cast stays: the sub-int32 shift width retype) — so the emitted code reads like the Go source (math cbrt; uses that stay genuinely untyped, feed other constants, or participate in constant folding conservatively keep the wrapper form):

const (
    C = 5.42857142857142815906e-01 // 19/35 = 0x3FE15F15F15F15F1
    G = 3.57142857142857150787e-01 // 5/14  = 0x3FD6DB6DB6DB6DB7
)
s := C + r*t
t *= G + F/(s+E+D/s)
const float64 C = 5.42857142857142815906e-01; // 19/35     = 0x3FE15F15F15F15F1
const float64 G = 3.57142857142857150787e-01; // 5/14      = 0x3FD6DB6DB6DB6DB7
var s = C + r * t;
t *= G + F / (s + E + D / s);

A complex constant emits a real complex value, built from its two halves by the same exact-float rendering and recombined in the postfix .i() form written imaginary literals use — as a property, because C# forbids const of a struct and both complex128 (System.Numerics.Complex) and complex64 are structs:

const cRational = 5.5 + 1.5i
const c64 complex64 = 1.5 + 2.5i
internal static UntypedComplex cRational => /* 5.5 + 1.5i */ 5.5D + 1.5D.i();
internal static complex64 c64 => /* 1.5 + 2.5i */ 1.5F + 2.5F.i();

A native-sized constant whose value doesn’t fit a C# const (e.g. ^uintptr(0)) falls back to the same property form with an unchecked cast. Note uintptr is a distinct golib struct, not an alias of System.UIntPtr — Go treats uint and uintptr as different types, and the struct preserves that identity.

Full detail: Reference → Constant Values — the exact-float, complex-halves, and local-const tightening rules, the wrapper’s value-conversion and value-comparison contracts, the unchecked native-int cast rules, wide-unsigned named consts, and the full uintptr conversion matrix.


Native and Narrow Integer Types

Go’s int/uint are platform-sized; C#’s are always 32-bit. C# 9’s native integers nint/nuint behave exactly like Go’s, so intnint, uintnuint (and uintptr → the uintptr struct). The fixed-width types keep readable same-named aliases (int32, byte, rune, …).

The one semantic gap is narrow arithmetic: Go evaluates int8/uint8/int16/uint16 math at that width with wrap-around, but C# promotes it to int. Where a narrow result flows into a narrow-typed slot, the converter inserts a cast back — which both compiles and restores Go’s wrapping:

var a, b uint8 = 200, 100
take(a + b)         // Go: 44 (300 mod 256)
uint8 a = 200, b = 100;
take((uint8)(a + b));   // wraps to 44, not 300

Full detail: Reference → Native and Narrow Integer Types — narrow-arithmetic casts across argument/assignment/return contexts, wide-const overflow folding, signed minima sign-folding, and the Index/Range nintint caveat.


Named Numeric Types and Constant Contexts

General untyped constant representation is covered in Constant Values. This section focuses on what happens after numeric context is known: Go defined types over numeric bases, constants that must flow into those named or native-width types, and the casts needed to keep C# overload resolution and operator binding aligned with Go.

A Go type over a numeric base — type Celsius float64, type Duration int64 — becomes a [GoType("num:…")] partial struct whose body (operators, comparisons, conversions to/from the underlying) the TypeGenerator fills in. It’s a distinct C# type that still behaves like its base, so method bodies read almost line-for-line the same:

type Duration int64                       // time/time.go
func (d Duration) Seconds() float64 {
    sec := d / Second
    nsec := d % Second
    return float64(sec) + float64(nsec)/1e9
}
[GoType("num:int64")] partial struct Duration;      // time/time.cs
public static float64 Seconds(this Duration d) {
    var sec = d / ΔSecond;
    var nsec = d % ΔSecond;
    return (float64)(int64)sec + (float64)(int64)nsec / 1e9D;
}

The wrapper carries the full operator surface (integer underlyings also get ~, shifts, and bitwise ops), so Word >> s stays a Word. Converting between a named type and a non-underlying basic routes through the underlying (traceArg(procs)(traceArg)(uint64)procs), mirroring Go’s numeric-conversion rules. Unsigned unary minus lowers to (T)0 - x (C# forbids unary negation, i.e., - prefix, on unsigned).

Full detail: Reference → Named Numeric Types and Constant Contexts — this is one of the deepest topics: ++/-- operators, to/from conversions, cross-assembly conversion operators, named slice/array/map wrappers, append element casting, shift-width and bit-mask casts, and the &^= bit-clear lowering.


Nil and Zero Values

nil maps to the golib NilType value nil (from go.builtin), which defines comparison operators so x == nil / x != nil work across slices, maps, channels, pointers, and interfaces — each defining what “nil” means for it (a nil map<K,V> reads zero values, has len 0, ranges empty, panics on write). In value position (a return, an assignment), nil is written default!:

func Unwrap(err error) error {      // errors/wrap.go
    u, ok := err.(interface{ Unwrap() error })
    if !ok {
        return nil
    }
    return u.Unwrap()
}
public static error Unwrap(error err) {     // errors/wrap.cs
    var (u, ok) = err._<Unwrap_type>();
    if (!ok) {
        return default!;
    }
    return u.Unwrap();
}

A nil→pointer conversion — Go’s typed nil, (*T)(nil) — instead yields the pointer type’s canonical typed nil instance (ж<T>.NilBox), so the boxed value keeps its Go dynamic type: any((*T)(nil)) != nil, %T prints *T, and the stdlib’s descriptor idiom reflect.TypeOf((*T)(nil)).Elem() resolves — a bare null erased all three:

var errorType = reflectlite.TypeOf((*error)(nil)).Elem()   // errors/wrap.go
internal static reflectliteType errorType = reflectlite.TypeOf(((ж<error>)nil)).Elem();

Go draws no distinction between a nil it was handed that way and a pointer’s zero value, so neither does the conversion — a pointer entering interface space carries its static type however it was produced. A non-empty interface gets that from its generated adapter; an any slot has no adapter, so the box passes through OrTypedNil(), which substitutes the canonical instance for a plain null:

var ip *int
fmt.Printf("%T %v\n", ip, any(ip) == nil)   // *int false
ж<nint> ip = default!;
fmt.Printf("%T %v\n"u8, ip.OrTypedNil(), ((any)ip.OrTypedNil()) == default!);

Reflection reaches the same boundary from the other side. reflect.Value.Interface() is Go’s packEface — an interface built from a type and a data word — so a nil *T read out of a slot packs as a non-nil (type=*T, value=nil) and a type assertion on it SUCCEEDS, dispatching the method on the nil receiver. The bridge re-encodes a null pointer-kinded slot read as that same canonical instance, which is what lets encoding/gob reach big.Int.GobEncode’s if x == nil arm for a zero-filled make([]*Int, 1) element.

Detail (pointer-identity rules, adapter seeding, the structural-vs-dereference nil distinction, and which slots the boundary covers): Canonical typed-nil pointer boxing and the reflection read path.

Zero-value reference-backed values are null-safe: a default! @string reads as "" rather than throwing.

Nilness is representation identity, not emptiness: s == nil on a slice is true exactly for the nil slice (null backing array), so a non-nil empty ([]byte{}, make([]T, 0), s[len(s):]) stays observably non-nil, and nil survives reslicing (nil[0:0]) and no-op appends — the distinctions Go programs (and the stdlib’s own tests) rely on. The same holds for a DEFINED slice type (type S []int): the generated wrapper’s == nil delegates to the wrapped slice<T>’s own == nil (representation nilness), so S{} is non-nil while the zero value is nil (named map/channel wrappers were already correct — their backing compares by reference).

Full detail: Reference → Nil and Zero Values — null-safe zero values and pointer-to-interface assignment through selector fields; and Reference → Nil-vs-empty slice identity — the full construction-identity enumeration.


Empty Interface (any)

Every Go type satisfies interface{} (spelled any), which behaves like .NET’s object, so the empty interface maps directly to any (a global alias for object). func(i interface{})void f(any i); map[any]stringmap<any, @string>.

One wrinkle worth knowing: a Go string literal normally emits as a "…"u8 ReadOnlySpan<byte>, which has no conversion to object, and a plain C# "…" boxes a System.String where Go boxes string. So a string literal materialized as any is boxed through @string at every interface position — argument included, so fmt.Println("x") emits fmt.Println((@string)"x"u8) — preserving Go string identity for a later x.(string), case string:, or ==. The (@string) cast is what makes the combination legal (it converts the ROM span to a heap string, which boxes); the u8 suffix is then free and keeps the literal’s bytes compile-time constant instead of transcoding them from UTF-16 on every evaluation. A NAMED string constant needs nothing (it is already emitted as an @string member), which is the exact mirror of the numeric rule below.

The numeric twin: Go materializes an untyped constant into an interface at its default type — untyped int → int (go2cs nint), untyped rune → rune (int32), untyped float → float64 — and every observation of an interface value dispatches on the boxed CLR type. So an untyped constant boxed as any is cast to that type at every interface position (Ꮡv.Store((nint)(42)), fmt.Sprintf("%s%c", d, (int32)(os.PathSeparator))), else a later x.(int)x._<nint>() — finds an Int32 and panics, a case int: falls through, and interface == reports unequal. Two renderings need it: a bare int literal, which C# makes System.Int32; and anything referencing a named untyped constant (const fsize = 5), which is a golib UntypedInt/UntypedFloat wrapper struct matching no Go type at all — leaving it uncast made fmt’s dynamic-type dispatch fall back to reflection and print {6 %!d(bool=false)} instead of 6 (go/token’s TestIssue57490). The variadic ...any slot and any map keys were once carved out as “cosmetic”; both were real divergences the moment the box was compared rather than printed (encoding/base32’s testEqual(…, n, 0) reported n = 0, expected 0 UNEQUAL; a map[any]V lookup by a real int value missed its literal-stored key), so neither is carved out now.

A related identity wrinkle: a deref-aliased pointer (a *T parameter or a pointer receiver) passed as any renders the box Ꮡp, not the deref’d value alias p — Go boxes the pointer, and dropping the box would store the pointed-to value, so a later x.(*T) would find a bare T and panic. This surfaced as fmt’s sync.Pool round-trip (ppFree.Put(p) then Get().(*pp)), which crashed every multi-call fmt program before the fix.

Full detail: Reference → Empty Interface (any) — the @string and default-type boxing across argument, return, assignment, composite-literal, map-key, and channel-send positions, and the box for a pointer value passed to an any argument.


Multi-Assignment and Evaluation Order

Go evaluates every right-hand operand before assigning, which C# expresses with tuple deconstruction:

x, y = y, x+y
(x, y) = (y, x + y);

The deconstruction is mandatory whenever the targets alias, and that includes fields — regexp’s inst.Out, inst.Arg = inst.Arg, inst.Out must not shatter into two stores, or both fields end up holding the original Arg:

(inst.Value.Out, inst.Value.Arg) = (inst.Value.Arg, inst.Value.Out);

Go’s partial redeclaration (a, b := f() where a already exists) reuses a and declares only the new names, so the converter emits var per newly-declared element: (frac, var e) = normalize(frac);. A blank element is a discard with no var (_ = fi;).

Full detail: Reference → Multi-Assignment and Evaluation Order — per-element var mechanics, escaping/heap-boxed tuple elements, interface-converting deconstruction, and address-taken value locals (the Ꮡ(value) copy-vs-box distinction).


Short Variable Redeclaration (Shadowing)

C# forbids a local from shadowing an enclosing local (CS0136). Where Go’s := legally shadows, the converter renames the inner variable with a Δ suffix and rewrites its references, leaving the outer one untouched (so its value is naturally preserved):

func sumWithLenLocal(buf []int) int {
    total := 0
    len := len(buf)          // a local shadowing the builtin
    for i := 0; i < len; i++ { total += i }
    return total + len
}
internal static nint sumWithLenLocal(slice<nint> buf) {
    nint total = 0;
    nint lenΔ1 = len(buf);           // renamed; the builtin call stays len(...)
    for (nint i = 0; i < lenΔ1; i++) { total += i; }
    return total + lenΔ1;
}

The mirror case — a local shadowing a package global — qualifies the global instead (runtime_package.Δtrace), which a local can never shadow. Related renames cover type-vs-method name collisions (Δfoo type vs foo method), closure parameters, and consts.

A collision rename is visible across packagestime declares both const Second and func (Time) Second() int, so the const is ΔSecond and every consumer must spell it that way:

d := 2 * time.Second                     // any Go program
var d = 2 * time.ΔSecond;                // the const, not the Second() method group

The consumer derives that spelling from the dependency’s own declarations, so it is the same whether or not time happens to be converted in the same run — which is what makes a standalone go2cs <dir> (and -recurse) conversion of such a program compile. It is likewise the same however the source named the type: a renamed type reached through a dot import is a bare ident with no package qualifier to rewrite, and it still resolves through the same imported alias (Info{…} and types.Info{…} both emit typesꓸInfo) — see Reference → A DOT-IMPORTED renamed type.

Full detail: Reference → Short Variable Redeclaration — a large family: forward-collision detection at every block level, package-function shadowing, builtin-method shadowing, box-name rules for renamed receivers/pointers, and nested-closure capture state.


Multi-Result Values and Comma-Ok Forms

Go functions returning (value, ok) / (value, error) become ordinary C# value tuples, destructured at the call site. The runtime’s own comma-ok forms (map read, type assertion) use a discard sentinel to select a second overload — for indexers, for assertions:

func Atoi(s string) (int, error) {        // strconv/atoi.go
    i64, err := ParseInt(s, 10, 0)
    if nerr, ok := err.(*NumError); ok {
        nerr.Func = fnAtoi
    }
    return int(i64), err
}
public static (nint, error) Atoi(@string s) {     // strconv/atoi.cs
    var (i64, err) = ParseInt(s, 10, 0);
    {
        var (nerr, ok) = err._<ж<NumError>>(); if (ok) {
            nerr.Value.Func = fnAtoi;
        }
    }
    return ((nint)i64, err);
}

The single-value assertion i.(T)i._<T>() panics on failure; the comma-ok i._<T>(ᐧ) returns safely. An assertion to a pointer type renders the box type: i.(*box)i._<ж<box>>().

Full detail: Reference → Multi-Result Values and Comma-Ok Forms — package-level var a, b = f() component reads, variadic pointer-arg boxing, named-func-result signatures, and variadic-closure params rebinding.


Slices and Arrays

Go slices and arrays convert to golib slice<T> and array<T>. A composite literal builds a C# array and projects it with .slice() / .array(); make uses a constructor:

primes := [6]int{2, 3, 5, 7, 11, 13}   // array literal
nums := []int{10, 20, 30}              // slice literal
buf := make([]byte, 4)                 // make
var primes = new nint[]{2, 3, 5, 7, 11, 13}.array();
var nums = new nint[]{10, 20, 30}.slice();
var buf = new slice<byte>(4);

A [N]T literal that writes fewer than N elements passes the declared length to the projection, because Go zero-fills the remainder — [8]byte{} is eight zero bytes, not an empty array. A full literal keeps the plain .array(), and a slice literal never pads:

seed := [8]byte{1, 2}   // 1, 2, then six zeros
var seed = new byte[]{1, 2}.array(8);

See the reference for the keyed/SparseArray form and the nested-array gap.

array<T> carries its element type but not its LENGTH — C# has no const generic to hold the N of [N]T — so wherever the length has to be recoverable at runtime it comes from the emitted code: a value measures itself, and a struct field reads the dimension back out of the field initializer the converter emits (= new(32)). A func parameter is the one position with neither, so it carries the dimension as an attribute instead — which is what makes reflect.TypeOf(f).In(0).Len() answer 32 rather than 0, and testing/quick generate a real 32-byte array rather than an empty one:

f1 := func(in [32]byte, sc Scalar) bool {  }
var f1 = ([GoArrayDims(32)] array<byte> @in, Scalar sc) => {  };

See the reference (A func PARAMETER is the one position an array’s LENGTH cannot be recovered from) for the delegate-instance read behind it.

append, len, make, and sub-slicing map to golib builtins/methods. A variadic ...T parameter arrives as params ꓸꓸꓸT, where ꓸꓸꓸT is a using alias for Span<T> whose identifier mirrors the Go name (...*RangeTableꓸꓸꓸжRangeTable, ...unsafe.PointerꓸꓸꓸunsafeꓸPointer), falling back to an inline params Span<T> for an element type that cannot form a legal alias identifier (a type parameter, or a constructed type such as []byte). At the top of the body, a variadic used only through len/cap, indexing, or range binds to the allocation-free stack view sslice<T>; a value that may escape, grow, or cross a closure/execution-wrapper boundary keeps the heap slice<T> fallback. From the real stdlib:

func Join(errs ...error) error {          // errors/join.go
    // ...
    e := &joinError{errs: make([]error, 0, n)}
    for _, err := range errs {
        if err != nil { e.errs = append(e.errs, err) }
    }
    return e
}
public static error Join(params ꓸꓸꓸerror errsʗp) {     // errors/join.cs
    var errs = errsʗp.sslice();
    // ...
    var e = (new joinError(errs: new slice<error>(0, n)));
    foreach (var (_, err) in errs) {
        if (err != default!) { e.Value.errs = append((~e).errs, err); }
    }
    return new joinErrorжerror(e);
}

Arrays are Go values: every transfer copies the whole array. array<T> is a struct over a shared T[], so the converter appends a strongly-typed .Clone() wherever an array value is read out of existing storage — assignment, range elements, composite-literal elements and struct fields, returns, channel sends, append elements, and function parameters (cloned in the callee preamble). Named array types clone the same way through their generated wrapper’s own Clone(), and the copy is deep for nested arrays ([2][3]int copies its inner arrays too, matching Go):

data := ints                          // an independent copy — writes to data never reach ints
for _, row := range m { row[0] = 9 }  // row is a per-iteration copy — m is never written
var data = ints.Clone();
foreach (var (_, v1) in m) { var row = v1.Clone(); row[0] = 9; }

A struct whose field is a fixed-size array carries the same shared T[] into a plain struct copy, so it clones at exactly the same sites. The converter stamps the struct with the fields that need the deep copy and go2cs-gen generates it, under a Δ-marked name so it cannot shadow a Go type’s own Clone method. crypto/sha256’s Sum is the real case — it copies the digest so the caller can keep writing, then destroys the copy finalizing it:

d0 := *d                 // sha256.go — Go copies the [8]uint32 state and [64]byte block INLINE
hash := d0.checkSum()
[GoType] partial struct digest { internal array<uint32> h = new(8);  }

// package_info.cs names the fields the copy must deep-copy, keeping the declaration Go-shaped
[GoValueClone("h", "x")] internal partial struct digest {}

ref var d0 = ref heap<digest>(out var d0);
d0 = d.ΔClone();         // sha256.cs — without the clone, checkSum destroyed the CALLER's state
var hash = d0.checkSum();

Go’s two array-pointer conversions are the exception to that copying: each yields a view of storage that already exists, so array<T> carries a (low, length) window and both emit an alias rather than a snapshot. (*[N]T)(s) windows a slice; (*[N]T)(unsafe.Pointer(p)) with p a *T windows the storage p is an element of — internal/poll’s console read buffer, filled by os’s own test through exactly this shape:

d := (*[4]byte)(dst)                                        // image/png writer.go — shares dst's array
n = copy((*[10000]uint16)(unsafe.Pointer(buf))[:n:n], s16)  // os os_windows_test.go
var d = (array<byte>.Alias(dst, 4));
n = copy((~array<uint16>.AliasPointer(buf, 10000)).slice(-1, n, n), s16);

A write through either has to reach the caller’s buffer; against a copy it is discarded silently, which is a wrong answer rather than a slow one. The value forms — [4]byte(s), *p — still copy, exactly as Go’s do.

Full detail: Reference → Slices and Arrays — named slice/array wrappers, pointer-to-array slicing, named-slice pointer reinterpretation, structural composite rendering, array value-copy cloning (deep for nested arrays), the struct-carrying-arrays clone, element-pointer array aliasing and slice-aliasing/write-through semantics.


Strings (@string and sstring)

Go’s string is represented by golib @string: an immutable byte string whose len, indexing, ranging, concatenation, and comparisons are byte-oriented like Go’s, not UTF-16-oriented like System.String. It also carries Go’s string header — a backing array plus an offset and length — so s[i:j] is an O(1) window over shared storage rather than a copy, which is what keeps the ubiquitous s = s[n:] and DecodeRuneInString(s[i:]) idioms linear instead of quadratic (detail). Plain string literals usually render as "..."u8 ReadOnlySpan<byte> values, then target-type into @string only when a heap string is actually needed. That keeps common literal-to-slice and literal-comparison forms allocation-free:

var s string = "ready"
b := []byte("hi")
@string s = "ready"u8;
var b = slice<byte>("hi"u8);

A string↔bytes conversion is a cast over the golib types: string(b.buf[b.off:])(@string)(b.buf[(int)(b.off)..]), and []byte(s)slice<byte>(s). []rune(s) decodes through the Go string model rather than the CLR string model. Literals with raw byte escapes that cannot be expressed faithfully as UTF-8 source (for example high \xHH bytes or greedy hex escapes) emit as byte-array-backed @string, preserving Go’s exact bytes.

A literal that materializes a value is hoisted to a package-scoped private static readonly field declared immediately above the function that first uses it, so it costs at most one allocation per program run instead of one per evaluation — Go’s own RODATA cost model:

func FormatBool(b bool) string {
	if b { return "true" }
	return "false"
}
// Hoisted @string literals (single allocation; Go keeps these in RODATA)
private static readonly @string trueˢ = "true"u8;
private static readonly @string falseˢ = "false"u8;

public static @string FormatBool(bool b) {
    if (b) { return trueˢ; }
    return falseˢ;
}

The ˢ suffix marks a converter-synthesized name, like for temporaries and Δ for renames. Hoisting covers value-materializing contexts only — returns, assignments, string and any arguments, map keys, named-string conversions — and deliberately skips the contexts where the inline literal is already free, or where a name derived from the literal’s content would read worse than the value itself: comparisons and concatenations (golib compares and concatenates a u8 span in place), []byte/[]rune sources (the copy is mandatory), format strings, composite-literal elements, func init() bodies, package-level initializers, and literals whose slug carries no information. A literal used only in any slots is emitted pre-boxed, so those sites allocate nothing at all. See the reference for the full inclusion/exclusion tables, the naming rules, and the initialization-order guarantee.

Named string types are real wrapper structs (type relationship string), so the generated type keeps the string surface: indexing, sub-slicing, len, comparisons, concatenation, constants, and method calls stay on the named type instead of collapsing back to plain @string. Concatenation matters twice over: Go keeps the named type across a +, so the wrapper carries its own + overloads (including against a u8 span) — without them C# falls back to string.Concat, handing back a System.String stripped of the type’s methods.

type Token string
func (t Token) First() byte { return t[0] }
const done Token = "done"
next := done + "-next"    // still a Token
[GoType("@string")] partial struct Token;
internal static readonly Token done = "done"u8;
public static byte First(this Token t) => t[0];
Token next = done + "-next"u8;

Most string([]byte) conversions must copy into @string — the price of Go’s immutable-string guarantee. Go’s own compiler elides that copy when the resulting string does not escape and its source is not modified while it is alive, letting the string alias the bytes in place. The converter recovers this common fast path with a second string type, sstring: a stack-only readonly ref struct that views a ReadOnlySpan<byte> with no allocation.

A provably-safe string([]byte) conversion emits sstring; anything that escapes stays @string (the implicit sstring@string conversion copies to the heap at that boundary, so correctness never depends on getting the analysis right — only performance does).

Safety is enforced two ways. Because sstring is a ref struct, the .NET compiler forbids every way a string could escape — a field, array, map, interface box, channel, closure, or a return past its data’s lifetime — so an over-reach is a compile error, not a silent bug. The one hazard the compiler cannot see — the source slice mutated while the view is alive — the converter’s escape analysis rules out. So sstring appears only for a non-escaping conversion used in read-only positions: a comparison, a switch tag, len/index, or a concatenation operand.

if string(hdr[:4]) == wantMagic {  }        // compare a slice against a []byte-derived string
switch string(cmd) { case "get": ; case "put":  }
if (((sstring)(hdr[..4])) == wantMagic) {  }   // mixed sstring/@string compare — no heap copy
var expr1 = ((sstring)cmd);                     // a string switch lowers to == comparisons
if (expr1 == "get"u8) {  } if (expr1 == "put"u8) {  }

Comparing an sstring against a "…"u8 literal, an @string, or another view runs zero-allocation directly over the backing spans — which is where the win shows: the eligible comparison idiom measures ~11–12× faster than the @string copy-and-compare. A repeated conversion in a loop is hoisted to a single reused view; everything that escapes simply stays @string.

Full detail: Reference → Strings (@string and sstring) — literal rendering, string↔[]byte/[]rune conversions, named-string wrapper behavior, high-\x-escape byte arrays, the exact sstring eligibility predicate, comparison / switch / concatenation forms, loop-invariant hoisting, and the SStringElision guard test.


Maps and Channels

Go maps and channels convert to golib map<K,V> and channel<T>; make becomes a constructor, and send/receive/select use runtime operators. Map reads honor Go’s nil-map and comma-ok semantics:

m := make(map[string]int)
c := make(chan int, 3)
u := make(chan int)             // unbuffered: rendezvous
unit, ok := unitMap[u]          // comma-ok read (time/format.go)
var m = new map<@string, nint>();
var c = new channel<nint>(3);
var u = new channel<nint>(0);        // capacity 0 — real rendezvous semantics
var (unit, ok) = unitMap[u, ];      // two-value indexer via the ꟷ sentinel

A nil key is an ordinary key in Go wherever the key type can be nil (map[any]V, map[error]V, map[*T]V), and it renders as default!m[nil] = "x"m[default!] = xˢ. Dictionary<K,V> rejects a null key outright, so golib’s backing store is a Dictionary subclass carrying a dedicated nil-key slot that every map member routes to; the test that finds it is a JIT-time constant, so a value-type key (map[string]V, map[int]V) compiles to exactly the code it did before and PerfMap stays flat.

An interface key compares by Go equality, not by wrapper identity. Go compares interface values by (dynamic type, dynamic value), and that one relation serves both == and map lookup. But a converted interface value is presented through whichever generated adapter its current static interface calls for, so asserting an Object to a narrower dependency hands back a different wrapper over the same receiver box. A map keyed by an interface therefore installs golib’s GoEqualityComparer, which projects the same builtin.AreEqual that emitted == uses and hashes the unwrapped root; without it the asserted value could not find its own entry, while == on that very pair still said true. That split is what stopped go/types from type-checking anything — initorder.dependencyGraph is exactly this shape — and it is scoped to interface/any keys, so concrete keys keep EqualityComparer<K>.Default’s fast path. See the interface map key section of the reference.

A m[string(b)] READ does not copy the key, matching the Go compiler’s own special case (runtime.slicebytetostringtmp): a lookup hashes and compares its key but never retains it, so the converter emits golib’s tmpstring(b) — a transient @string windowing the slice’s live bytes, zero allocation. Everywhere the string escapes (a store m[string(b)] = v, delete, a return) the copying conversion stays:

if v := commonHeader[string(a)]; v != "" { return v, true }   // net/textproto reader.go
@string v = commonHeader[tmpstring(a)]; if (v != ""u8) { return (v, true); }

golib’s channel<T> is a faithful port of Go’s runtime channel (hchan + selectgo): an unbuffered send really waits for a receiver, cap/len report Go’s values, a blocking select commits exactly ONE case chosen uniformly at random among the ready ones, and close/panic semantics match Go — see the channel runtime section of the reference. One channel has an owner that can take a value BACK: Go 1.23’s synchronous timer channel, where Stop/Reset guarantee that no tick from before the call can be received after it — so time.Timer.C reports len and cap of 0 even while it holds a tick, and the pre-1.23 “drain the channel if Stop returned false” idiom is unnecessary. golib models it with Go’s own hchan.timer hook rather than a time special case.

A goroutine over a select — the concurrency core — lowers to goǃ(...) and a switch over select(...), with ᐸꟷ marking a receive-case and ꟷᐳ performing the receive. Every case’s operands are hoisted into select-scoped temps (selᴛN) emitted in strict source order and evaluated exactly once at select entry — Go’s evaluation rule — so the registration list names only temps: a receive case’s channel operand (used by both the registration and the winning case’s guard), and a send case’s whole registration call, which only builds the case descriptor and so moves no send:

go func() {                     // context/context.go
    select {
    case <-parent.Done():
        child.cancel(false, parent.Err(), Cause(parent))
    case <-child.Done():
    }
}()
goǃ(() => {                     // context/context.cs
    var sel2 = parent.Done();
    var sel3 = child.Done();
    switch (select(ᐸꟷ(sel2, ꓸꓸꓸ), ᐸꟷ(sel3, ꓸꓸꓸ))) {
    case 0 when sel2.ꟷᐳ(out _): {
        child.cancel(false, parent.Err(), Cause(parent));
        break;
    }
    case 1 when sel3.ꟷᐳ(out _): { break; }}
});

With a default: clause the select becomes non-blocking: the same registrations feed trySelect(…), which commits at most ONE ready case (uniformly at random among the ready ones) and returns -1 when none is — so the C# default: label runs exactly when Go’s would. A full channel falls to the default: exactly as in Go, and a send on a closed channel panics even though a default exists:

select {                        // os/signal/signal.go
case c <- sig:
default:                        // send but do not block for it
}
var sel1 = c.ᐸꟷ(sig, ꓸꓸꓸ);            // os/signal/signal.cs
switch (trySelect(sel1)) {
case 0: {
    break;
}
default: {
    break;
}}

Full detail: Reference → Maps and Channels — the nil map key’s dedicated slot, the m[string(b)] no-copy read key (tmpstring), named map/channel types, constrained map access through type parameters, the real channel runtime (hchan + selectgo: rendezvous, cap/len, single-fire, uniform-random), and full select lowering (terminating/empty clauses, escaping comm-clause bindings).


Generic Constraints

A Go generic constraint becomes a C# where clause. Type-set constraints lift to the matching golib/.NET interface ([]TISlice<T>, [N]EIArray<E>, map[K]VIMap<K,V>, chan TIChannel<T>), and an operator-bearing type set additionally lifts the System.Numerics operator interfaces so the body’s +/</== compile. comparable emits no C# constraint beyond new() (no C# interface can admit Go’s full ==-able set): Go’s checker already validated every instantiation, and emitted equality on a type-parameter operand routes through golib’s AreEqual. A generic struct’s own generated Equals follows the same rule per field — fields whose type carries its own == (a ж<T> pointer, a golib wrapper, another [GoType] struct) compare with ==, and only genuine type-parameter fields route through AreEqual.

type Ordered interface {                  // cmp/cmp.go
    ~int | ~int8 | /* … */ | ~float64 | ~string
}
func Less[T Ordered](x, y T) bool {
    return (isNaN(x) && !isNaN(y)) || x < y
}
[GoType("operators = Sum, Comparable, Ordered")]      // cmp/cmp.cs
partial interface Ordered<ΔT> { /* type set + derived operators, as comments */ }

public static bool Less<T>(T x, T y)
    where T : /* Ordered */ IAdditionOperators<T, T, T>, IEqualityOperators<T, T, bool>,
              IComparisonOperators<T, T, bool>, new()
{
    return (isNaN(x) && !isNaN(y)) || x < y;
}

Full detail: Reference → Generic Constraints — array-core ~[N]E lifting, single-term pointer constraints ([P *T]ж<T>), method-set interface constraints and self-referential proxies, comparable, per-field generic-struct equality, unions (string | []byte), and explicit type-argument handling.


Type Aliasing

Go has two forms. A type definition (type Celsius float64) is a distinct type sharing an underlying; because converted types are structs (no inheritance), the source generators emit the bridging (implicit conversions to the underlying, interface implementations, receiver-method proxies). A type alias declaration (type P = *bool) is true aliasing, emitted as a C# global using:

type P = *bool
type table = map[string]int
global using P = go.ж<bool>;
global using table = go.map<@string, nint>;

Full detail: Reference → Type Aliasing — self-boxing pointer conversions, keyword-safe global using RHS rendering, types.Unalias at type-switched decision points, and same-package alias-target namespace qualification.


Delegates to Value Receiver Instances

In Go a function is a value, and a value-receiver method value captures a copy of the receiver at the moment it’s taken — a subtlety that surprises non-Go programmers:

d := data{name: "James"}
f1 := d.printName
f1()                 // "Name = James"
d.name = "Gretchen"
f1()                 // "Name = James" again — f1 bound a copy of d

To preserve this, the converter copies the receiver value into the delegate’s capture (a snapshot taken at assignment time), rather than capturing by reference. Method expressions ((*T).M), bound method values, pointer-receiver method values, and conversions to named func types each have a tailored emission (a cast to the concrete delegate, a box-bound method group, or new NamedDelegate(...)).

A pointer-receiver method value is the mirror image and needs the opposite treatment: c.dec is Go shorthand for (&c).dec, so it must alias the receiver, not copy it. That implicit address-of heap-promotes the local exactly like an explicit &c would — the escape analysis treats the two identically, and the method group binds to the box:

c := counter{n: 100}
applyInt(c.dec, 5, 7)        // (&c).dec — c.n is 88 afterwards
ref var c = ref heap<counter>(out var c);
c = new counter(n: 100);
applyInt(c.dec, 5, 7);      // Ꮡc aliases c

That holds in argument and assignment position alike (f = c.dec emits the same Ꮡc.dec group), and in assignment position it also means no receiver snapshot is taken — there is no copy to snapshot.

A direct call c.dec() is not a method value — it binds C#’s this ref extension receiver against the variable and needs no box.

Full detail: Reference → Delegates to Value Receiver Instances — method expressions (local & foreign), bound/interface/pointer/value-receiver method values, the go-statement sibling, named and generic func-type conversions.


Defer / Panic / Recover

A Go function that defers or recovers keeps its body exactly where Go put it: the statements are emitted inline in the method, inside try/catch/finally, beside a GoFrame local that holds this call’s defer list. The catch parks a panic where recover() can read it; the finally drains the deferred calls, which is Go’s guarantee that they run on every exit path; and the frame is a ref struct, so it lives in the stack frame and allocates nothing. A deferred call registers with defer(fn, args…, ref ᒐ) — the arguments are captured there because Go evaluates them at the defer statement. panic(x) lowers to throw panic(x):

func withLock(lk sync.Locker, fn func()) {   // database/sql/sql.go
    lk.Lock()
    defer lk.Unlock() // in case fn panics
    fn()
}
internal static void withLock(sync.Locker lk, Action fn) {   // database/sql/sql.cs
    GoFrame  = default;
    try {
        lk.Lock();
        defer(lk.Unlock, ref );
        // in case fn panics
        fn();
    }
    catch (Exception ex) when (GoFrame.IsPanic(ex, out PanicException? p)) { GoFrame.Capture(p); }
    finally { .Run(); }
}

A function with named results that deferred code mutates declares them ahead of the try and returns them after the finally, because Go runs the deferred calls after the results are assigned and before the caller sees them — which a finally cannot do to a value a return has already evaluated. Every exit inside the try therefore leaves through a goto, which runs the finally exactly as a return would.

An unrecovered panic — even in a goroutine — crashes the process exactly as in Go: golib’s AppDomain.UnhandledException backstop writes the panic: … report to stderr and exits with code 2.

recover() is a static call reading the one thread-local slot the emitted catch parked the panic in — which is what lets a deferred closure recover without holding any handle on the frame that registered it. A re-panic is throw panic(err):

if err := recover(); err != nil {   // fmt/print.go
    // ...
    if p.panicking { panic(err) }
var err = recover(); if (err != default!) {   // fmt/print.cs
    // ...
    if (p.panicking) { throw panic(err); }

A traceback taken while a panic is being handled (runtime.Stack, debug.Stack) reports what Go reports. Go keeps the panicking frames on the stack until the panic completes; the CLR unwinds them before a finally-based defer runs, so golib snapshots the panic’s origin at the first catch — and a re-panic inherits it, which is what keeps the origin visible through Go’s defer func(){ panic(recover()) }() idiom. The frames render in Go’s shape (sync_test.onceFuncPanic() over a tab-indented file:line), not the CLR’s at go.sync_test_package.onceFuncPanic(…), because a traceback is observable output that programs and tests read by package-qualified name.

The programmatic traceback — runtime.Caller, runtime.Callers, Frames.Next — walks the same managed stack, filtered to the frames the Go source declares, so relative depths and skip counting behave as in Go (go2cs’s own adapter shells and generated forwarders are invisible, exactly as Go’s interface dispatch adds no frame). The one honest difference is file/line: they name the converted .cs position, because that is the source the running program actually has.

Full detail: Reference → Defer / Panic / Recover — the frame’s emitted forms and why the body is not a lambda, the named-result goto exit, the registration ladder, unrecovered-panic process exit (stderr + code 2), named-delegate/builtin callees, value-returning goroutine wrapping, func-literal argument capture hoisting, and box-bound deferred pointer-receiver methods; plus Reference → runtime.Stack renders a GO-shaped traceback.


Expression Switch Statements

Go’s expression switch (no automatic fall-through) usually lowers to if / else if, which handles cases whose labels aren’t C# compile-time constants (variables, static readonly consts, addresses). When every label is a constant and there’s no fallthrough, a real C# switch is used. A tag-less switch { case cond: } lowers to a switch over the sentinel with each arm a when guard:

switch {                              // path/path.go
case path[r] == '/':
    r++
case path[r] == '.' && (r+1 == n || path[r+1] == '/'):
    r++
}
switch () {                          // path/path.cs
case {} when path[r] is (rune)'/': {
    r++;
    break;
}
case {} when path[r] == (rune)'.' && (r + 1 == n || path[r + 1] == (rune)'/'): {
    r++;
    break;
}}

fallthrough expands to an if-chain with a fall flag and goto; a switch-targeting break inside an if-else arm is wrapped in a one-shot do { … } while (false). Because the chain emits clauses in source order, a default: that Go places before some of its cases is guarded on a predicate precomputed over every case label — never on the running match flag, which has not yet seen the arms below it.

Full detail: Reference → Expression Switch Statements — constant-vs-runtime label detection, static readonly tags, fallthrough + guarded-default returns, non-trailing default clauses, and index/named-type case labels.


Type Switch Statements

A Go type switch maps cleanly to C#’s type-pattern switch. The dynamic type comes from .type(), and each case T: binds the value with a type pattern:

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)
    }
}
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: {
        // ...
    }}
}

Cases that match an anonymous interface (case interface{ Unwrap() error }:) synthesize a named [GoType("dyn")] interface and test it with case {} Δx when Δx._<is_typeᴛ1>(out var x):.

Full detail: Reference → Type Switch Statements — the tag-evaluates-once guarantee, default-arm binding, astral rune literals, and generic/embedded arms.


Labeled Control Flow and Loop Variables

Go labels sit immediately before their statement; C# reproduces the behavior with a placed label and a goto:

Outer:
    for i := 0; i < n; i++ {
        for j := 0; j < m; j++ {
            if done { break Outer }
        }
    }
    for (nint i = 0; i < n; i++) {
        for (nint j = 0; j < m; j++) {
            if (done) { goto break_Outer; }
        }
    }
    break_Outer:;

Full detail: Reference → Labeled Control Flow and Loop Variables — break vs continue label placement, labels on empty statements, and per-iteration loop-variable semantics (Go 1.22).


Struct Types

Go structs become C# structs (stack-friendly; heap-boxed as ж<T> when they escape). The converter emits a [GoType] partial struct with just the fields; the TypeGenerator synthesizes equality, ISupportMake, and embedding promotion. Access modifiers follow Go’s exported/unexported naming:

type List struct {                    // container/list/list.go
    root Element
    len  int
}
[GoType] partial struct List {        // container/list/list.cs
    internal Element root;
    internal nint len;
}

Inline/anonymous types are “lifted” out (a local type x struct{…} in mainmain_x; an anonymous struct → settingsᴛ1) from any depth of the declared type — []*struct{…} and map[K]*struct{…} lift exactly as a bare struct{…} does. The empty struct{} maps to the shared golib EmptyStruct, and an empty interface{} field to any — neither is lifted.

Full detail: Reference → Struct Types — field-name collisions in generated equality, combined field lines, local/anonymous-type lifting (and the recursive descent that reaches an anonymous type through pointer/slice/map/channel composition), and recorded implicit conversions between structurally-identical anon structs.


Struct Type Embedding

Go uses embedding instead of inheritance. Since C# structs can’t inherit, the TypeGenerator adds a field for the embedded type and promotes its fields and methods — transitively through every level, and for both value and pointer (*T) embeds:

type reverse struct {                 // sort/sort.go
    Interface                         // embedded — Len/Less/Swap promoted
}
func (r reverse) Less(i, j int) bool {
    return r.Interface.Less(j, i)     // this method overrides the promoted Less
}
[GoType] partial struct reverse {     // sort/sort.cs
    public Interface Interface;        // the embed becomes an explicitly-named field
}
internal static bool Less(this reverse r, nint i, nint j) {
    return r.Interface.Less(j, i);
}

Promoted-embed structs construct through a generated constructor (never default, which would leave null boxes). Cross-package embeds resolve through the compiled type’s metadata, and pointer-receiver methods promoted through a value embed are routed at the call site (t.of(timeTimer.Ꮡtimer).modify(…)) so writes land on the real storage. Such a method is also emitted as a ж<T>-receiver extension on the outer type, because that emitted set is what golib reads back at run time as the type’s Go method set — so a promotion the generator skips is not a missing shortcut but a Go method the type is then judged not to have, and every duck-typed assertion against it quietly misses. A field promoted through a pointer embed takes the hop before its reference is built, so its address is rooted where Go roots it — f.pfd for type File struct{ *file } is f.file.pfd, one address whichever spelling reaches it:

// os/File — the generated promoted field reference
internal static ж<FD> pfd(ref File instance) => instance.@file.of(global::go.os_package.file.pfd);

Full detail: Reference → Struct Type Embedding — transitive/pointer promotion, zero-value construction, cross-package (metadata) embeds, pointer-embed field identity, interface-adapter projection through embeds, and box-receiver primaries.


Interfaces

Go interfaces are duck-typed. The converter emits each user interface as a [GoType] partial interface, and the ImplementGenerator discovers which concrete types satisfy it and emits the implementing glue plus implicit conversions — so assigning a concrete value to an interface variable is direct, no reflection:

type Color interface {                // image/color/color.go
    RGBA() (r, g, b, a uint32)
}
type RGBA struct { R, G, B, A uint8 }
func (c RGBA) RGBA() (r, g, b, a uint32) { /* … */ return }
[GoType] partial interface Color {    // image/color/color.cs
    (uint32 r, uint32 g, uint32 b, uint32 a) RGBA();
}
[GoType] partial struct ΔRGBA {       // Δ-renamed: the struct name collides with its RGBA() method
    public uint8 R, G, B, A;
}
public static (uint32 r, uint32 g, uint32 b, uint32 a) RGBA(this ΔRGBA c) { /* … */ return (r, g, b, a); }

Each “concrete implements interface” pairing is recorded as [assembly: GoImplement<ΔRGBA, Color>] for the generator to consume. The well-known built-ins (error, fmt.Stringer, …) are hand-written in golib but implemented the same duck-typed way. A cross-package satisfaction is witnessed by the idiomatic var _ I = T{} assertion in the type’s own package.

A record is exported, so an importer reads it back and skips work it does not need. image/png writing d.palette[i] = color.RGBA{…} emits exactly that — the bare struct into the color.Color slot — because image/color’s assembly already carries ΔRGBA : Color. Only when the declaring assembly cannot realize the pair as a partial struct (a named FUNC type, which is a C# delegate — net/http’s HandlerFunc) does the importer wrap the value in its own <pkg>_<T>ᴠ<Iface> adapter class.

A pointer-sourced record ((Pointer = true)) answers the same question for *T, and its answer is simpler: that record is the declaring assembly’s public <T>ж<Iface> adapter class, so the importer references it rather than minting a local one. text/template’s s.walk(value, t.Root) emits new parse.ListNodeжNode(t.Root) — reaching into text/template/parse — not a second adapter of its own:

state.walk(value, new parse.ListNodeжNode(t.Root));   // text/template/exec.cs

Both halves of that decision — the one key spelling every record and cast site share, and the partial-struct trust rule the VALUE form additionally needs — are in Reference → A foreign implement record is keyed in ONE spelling.

Beyond one bounded exception, a record is only written for a conversion the source declares — an assignment, a call argument, a var _ I = T{} witness. It is not inferred in general, because a compile-time inference cannot be complete: a dynamic type may live in a package converted after the interface’s own (io/fs is converted before os, so nothing in fs could record os.dirFS) — and an interface literal (x.(interface{ Len() int })) can never be recorded at all. The exception is the case where inference IS complete: when a type and an EXPORTED interface are declared in the same package, both are in hand as that package converts, so the pairs it satisfies are recorded even with no cast anywhere — encoding/binary’s var BigEndian bigEndian carries no var _ ByteOrder = BigEndian, and without the record every consumer minted a binary_bigEndianᴠByteOrder wrapper that became a second identity for the value (89 constructions across the stdlib). The pointer method set is recorded on the same reasoning, with one extra gate: a (Pointer = true) record is consumed by NAMING the generated adapter class, and that class is public only when both the type and the interface are exported, so an unexported participant is excluded. Named FUNC types and generics are excluded from both forms — see Reference → A package records the pairs it SATISFIES. Structural satisfaction is resolved at RUN TIME instead: TypeGenerator emits two runtime duck-typing shells beside every non-generic, non-constraint, non-empty interface — named or anonymous alike — found through a [GoInterfaceShell] stamp: a delegate-bound generic shell for a pointer-sourced value (ж<X>) and a reflective object-held shell for a value-sourced one (os.dirFS, a [GoType("@string")] struct). golib’s AdapterBinder picks the tier, owns all binding, and is fail-soft — a pair it cannot build MISSES, exactly as Go answers. A declared record still wins first, as the ~1.1 ns nominal fast path; the shells answer everything else. This is the ONLY duck-typing surface a converted interface has, and the only one it needs: there are no per-interface conversion methods to reach reflectively (which Native AOT could not close) and no converter-side structural guessing at named-interface pairs:

[global::go.GoInterfaceShell(typeof(ΔSpeaker<>), typeof(ΔSpeakerObj), "Speak")]
public partial interface Speaker { }

Full detail: Reference → Interfaces — a large topic: the runtime shells and their AOT tiering, cross-package pointer/value adapters, unexported-sealing markers, keyword-named method escaping, publicized unexported types, structural (C# inheritance) satisfaction, and adapter accessibility.


Pointers

Pointer conversions use the golib heap box ж<T> (read “zhe”). Taking an address uses ; an escaping local is allocated with heap(...); a field/element address goes through .of(Type.ᏑField) / .at<T>(i) / Ꮡ(slice, i). A pointer parameter is deref-aliased with ref var x = ref Ꮡx.Value, and writes through a pointer field use .Value:

func (l *List) insert(e, at *Element) *Element {   // container/list/list.go
    e.prev = at
    e.next = at.next
    e.prev.next = e
    e.next.prev = e
    e.list = l
    l.len++
    return e
}
internal static ж<Element> insert(this ж<List> l, ж<Element> e, ж<Element> at) {  // list.cs
    ref var l = ref l.Value;
    ref var e = ref e.Value;
    ref var at = ref at.Value;
    e.prev = at;
    e.next = at.next;
    e.prev.Value.next = e;      // write through the pointer field
    e.next.Value.prev = e;
    e.list = l;
    l.len++;
    return e;
}

The box’s Value is the strict (nil-panicking) dereference; ValueSlot is its no-check twin; a package-level global whose address is taken is backed by a real box so &global writes are observed. Using ж<T> rather than C# ref sidesteps escape-analysis complications, at the cost of an occasional heap allocation.

Where no escape exists, the box does not either — the ref-lowering. An unexported package-level function’s pointer parameter whose every use is a dereference (or a forward into another such position) emits as a C# ref T parameter, and every call site passes a ref expression instead of minting a box: ref reads as Go’s &, the signature reads as Go’s *T, and an address-taken local whose address only feeds such positions reverts to a plain stack local (no heap() box, no pinnable slot). Any use the classifier does not positively recognize — identity/nilness, escapes, unsafe, method calls on the pointer, re-points, exported/func-value/linkname/hand-owned functions — keeps the boxed convention, and defer f(&x) / go f(&x) sites stay boxed with the thunk deriving the ref at invoke time. A nil base at a lowered field address panics eagerly via golib’s zero-allocation nonnil (Go’s timing); a nil pointer argument still enters the callee and faults at first use:

func p224Sub(out1, arg1, arg2 *p224MontgomeryDomainFieldElement)   // nistec/fiat/p224_fiat64.go
p224Sub(&e.x, &t1.x, &t2.x)
internal static void p224Sub(ref p224MontgomeryDomainFieldElement out1, ref p224MontgomeryDomainFieldElement arg1, ref p224MontgomeryDomainFieldElement arg2)
p224Sub(ref nonnil(ref e).x, ref nonnil(ref t1).x, ref nonnil(ref t2).x);

Detail (the classification whitelist, the seven call-site emission rows and their boxed fallbacks, the hoisted-temp rule for wrapper reinterprets, the locals reversion, the nil doctrine and the defer/go carve-out): A pointer parameter whose every use is a dereference is a ref parameter.

An ENTRY alias — the ref a pointer RECEIVER or pointer PARAMETER binds on the way in — must not use Value. Go permits calling a method through a nil *T, and equally permits passing one: the body RUNS, and the panic happens only where it dereferences the pointee. That is why os’s fifteen nil-tolerant *File methods return ErrInvalid instead of panicking, and why internal/concurrent’s newIndirectNode(nil) — which merely stores its argument — is not an error at all. So every entry alias uses DerefOrNull(), which binds a null ref for a nil box: legal to hold, and it faults on first use, so the panic is deferred to Go’s own point rather than raised at entry (or, as a shared default(T) slot would, lost entirely):

func (f *File) Chdir() error {                  // os/file_posix.go
    if err := f.checkValid("chdir"); err != nil { return err }
    ...
}
public static error Chdir(this ж<File> f) {    // file.cs
    ref var f = ref f.DerefOrNull();           // binds; a nil receiver does NOT throw here
    { var err = f.checkValid(chdirˢ); if (err != default!) { return err; } }
    ...
}

A pointer PARAMETER binds identically — ref var parent = ref Ꮡparent.DerefOrNull(); — and so does the re-alias after a pointer is re-pointed, since a repoint is not a dereference either. The fault surfaces as Go’s own runtime error: invalid memory address or nil pointer dereference, and is recoverable. Detail (both emission sites — the converter preamble and go2cs-gen’s ref-receiver bridge — and the three retired body analyses that used to admit nil parameters one shape at a time): A pointer RECEIVER’s deref alias is nil-DEFERRING and A pointer PARAMETER is nil-deferring for exactly the reason a receiver is.

Pointer equality is by address, so ж<T>.Equals compares each referent shape by its real storage, not by the box: a struct-field ref by (source object, field identity), and an element ref by (backing array, absolute index). That last canonicalization is load-bearing — Ꮡ(slice, i) boxes the slice HEADER anew on every call, so comparing the boxes made &s[0] == &s[0] false, and hashing them put two aliasing element pointers in different map[*T] buckets. Reducing to the backing array plus Low + index makes every Go alias of one element equal: &s[1:][0] == &s[1], an in-capacity append result, and &a[:][i] == &a[i].

Identity and nilness are structural — properties of the storage, never of the value stored there. A standard heap box is its storage, so it compares and hashes by its own identity (two boxes over one referent are two addresses, as &c == &d is false in Go), while two boxes aliasing the same native address are one pointer. IsNilPointer answers “is this THE nil pointer” and drives every identity question; the value-peeking IsNull survives only where reading the slot is the actual question, because a real address whose pointee is nil (&i with i == nil) and a field/element reference box are both perfectly good addresses.

The same reasoning answers lifetime: because the box is an expression temporary, anything asking “when does this object die?” or “is this the same object?” asks the referent, exposed as ReferentObject — the backing storage for an element ref, the root allocation for a (possibly nested) field ref. runtime.SetFinalizer(&buf[0], f) finalizes buf’s allocation, as in Go, rather than the throwaway ж<byte> the argument expression allocated; and sync.Cond’s copy detector, whose Go implementation stores its own address (unsound on a moving collector), compares root-allocation identity instead.

A pointer reinterpret(*U)(p) between two types that share an underlying — names p’s own storage in Go, so a write through the derived pointer is visible through p. The converter emits golib’s aliasing p.Reinterpret<T, U>() rather than boxing a converted copy. flag’s newBoolValue returning (*boolValue)(p) is the shape that makes the difference visible: under the copy form a parsed flag never reached the caller’s variable. And it answers address stability. Go’s collector never moves a heap object, so uintptr(unsafe.Pointer(&x)) names an address that stays valid while native code uses it; the CLR’s does move them, so go2cs pins the root storage whenever a pointer’s address is taken and holds it for that pointer’s lifetime — a heap box pins its own value slot, an element ref the backing array, a field ref the allocation containing the field. That is also why a standard heap box keeps an unmanaged pointee’s value in a one-element array rather than in a field of the box: a class carrying references cannot be pinned at all, so the value needs somewhere pinnable to live. Without this, every address handed to a syscall was a former address — a collection during a blocking ReadFile moved the byte-count box out from under the kernel, the count stayed zero, and internal/poll reported that as a premature io.EOF.

Full detail: Reference → Pointers — per-iteration range-variable boxes, wide-index narrowing on element addresses, element/unsafe.StringData pointer identity, pointer-typed globals & double-pointer walks, closure capture of boxed locals, unsafe.Pointer conversions, and reinterpret casts.


Implicit Pointer Dereferencing

Go auto-dereferences pointers on field access and method calls. The converter binds a ref local to the box’s value for a pointer parameter, so the body reads like Go:

func PrintValPtr(ptr *int) {
    fmt.Printf("Value available at *ptr = %d\n", *ptr)
    *ptr++
}
public static void PrintValPtr(ж<nint> ptr) {
    ref var ptr = ref ptr.Value;
    fmt.Printf("Value available at *ptr = %d\n"u8, ptr);
    ptr++;
}

A pointer local dereferences through its box on access — a read as (~x).field, a write as x.Value.field = … (the assignable form). Promoted fields, nested LHS chains, ++/--, and indexed targets all thread the same assignment context so the write path stays assignable.

Full detail: Reference → Implicit Pointer Dereferencing — selector-base deref detection, nested LHS .Value chains, index-expression assignment targets, and *p.field field-deref through parameters/receivers.


The go.golib support namespace

golib’s hand-written support types (SparseArray<T>, PinnedBuffer, HashCode, …) live in the go.golib child namespace — deliberately not go.<any Go package name>, because a child namespace visible from every referenced assembly would win simple-name lookup over an import alias (go.runtime would shadow using runtime = runtime_package;, CS0576). The general form of that collision — a real parent/child package pair — is handled by Δ-renaming the import alias (using Δruntime = …).

Full detail: Reference → The go.golib support namespace — the collision reasoning and the transitive-closure alias-rename pre-pass (incl. foreign renamed-type alias resolution).


Source Generators

Several Go semantics can’t be written directly in C#, so the converter emits compact attributed partial declarations and lets Roslyn source generators (src/gen/go2cs-gen/, referenced as an analyzer by every converted project) synthesize the rest at compile time — keeping the visible code close to Go. The principal generators:

Common attributes: [GoType], [GoRecv], [GoTag], [GoPackage], and the test-only [GoTestMatchingConsoleOutput].

An inline [GoType] declaration is deliberately bare so it reads like the Go original — but a C# nested type with no modifier is private, and a generator can’t see the partial it is about to emit. So package_info.cs carries a TypeAccessibility section inside the package class that pins each type’s real accessibility in source, one condensed line per type, ahead of generation:

[GoType] partial interface Closer {          // io/io.cs — Go-shaped, no modifier
    error Close();
}
public static partial class io_package {     // io/package_info.cs
    // <TypeAccessibility>
    internal partial struct discard {}
    public partial interface Closer {}
    // </TypeAccessibility>
}

Full detail: Reference → Source Generators.


Manually-Converted Declarations

A few Go declarations can’t be faithfully auto-converted: their semantics depend on constructs the CLR doesn’t have — a managed pointer hidden inside an integer (runtime’s guintptr/puintptr/muintptr, a uintptr holding a *g the Go GC must not see), a two-word interface layout walked through unsafe.Pointer (reflect), or a Go-runtime primitive with no managed equivalent (a scheduler continuation, a sleeping semaphore). The rule: managed reality beats raw reinterpretation — hold the ж<T> box or any directly instead of round-tripping through a uintptr/unsafe.Pointer the .NET GC can’t see, and reimplement the observable contract rather than the unportable mechanism.

Two mechanisms deliver it. Whole-file [module: GoManualConversion] makes the converter skip emission for that file and redirect it to a non-compiled <name>.cs.auto review sibling, so a reconvert can never touch the hand-owned .cs. A type-level registry instead skips only the listed types/methods and points at a hand-written *_impl.cs companion beside the rest of the auto-converted file.

runtime.Gosched is the smallest worked example. Go’s body is a scheduler continuation that needs mcall, a compiler intrinsic with no CLR equivalent, so runtime/managed_impl.cs implements the contract — “yield the current thread” — instead of the mechanism:

// Go — runtime/proc.go: the body is a scheduler continuation run on the system stack
func Gosched() { checkTimeouts(); mcall(gosched_m) }
// C# — runtime/managed_impl.cs: the CONTRACT, on the managed scheduler
public static void Gosched() { Thread.Yield(); }

The same rule hand-owns sync/atomic.Value (stores the boxed any directly, Volatile/Interlocked for the atomics), the reflection bridge (reflect/internal/reflectlite carry a boxed managed value plus a synthetic descriptor stamped with the real System.Type), sync.Pool’s eface ring (a single any? slot with null as the empty sentinel), sync.Cond’s copy detector (compares root-allocation identity instead of a GC-unsound stored address), internal/weak.Pointer (a short WeakReference over the ж<T> box, with a ConditionalWeakTable standing in for the runtime’s canonical per-address weak handle so two weak pointers to one object still compare equal), time’s runtime timers (one dedicated thread servicing a deadline-ordered heap on the Windows high-resolution timer), and the runtime’s whole process-control surface (GC, GOMAXPROCS, Gosched, LockOSThread, Goexit) as its contracts rather than its scheduler-level mechanics. The same “realize, don’t stub” instinct also ports an asm-backed architecture layer for real wherever .NET exposes the same instructions the .s file issues — hash/crc32’s SSE4.2 CRC32 and PCLMULQDQ folding.

On Linux the whole kernel boundary is one hand-own. Go funnels every syscall through a single assembly function, internal/runtime/syscall.Syscall6, so the managed corpus needs exactly one native binding — glibc’s syscall(2) — and the entire generated wrapper surface (open, read, write, stat, getrlimit, the epoll helpers) lights up behind it:

// Go — internal/runtime/syscall/syscall_linux.go: no body, no linkname, raw metal
func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr)
// C# — internal/runtime/syscall/linux/syscall_linux_impl.cs
[LibraryImport("libc", EntryPoint = "syscall", SetLastError = true)]
private static partial nint libc_syscall(nint number, nint a1, nint a2, nint a3, nint a4, nint a5, nint a6);

Every native binding in the corpus is [LibraryImport] rather than [DllImport], on both operating systems, for one reason: [DllImport] answers a signature it cannot marshal by silently marshalling a COPY, so a kernel writing through the pointer writes into a temporary the caller never reads — a wrong answer at run time. The source generator makes that a compile error instead, which turns the per-struct layout risk of routing Go’s kernel boundary through managed structs into a build-time question. It costs /unsafe unconditionally (SYSLIB1062), and since the .csproj is regenerated on every transpile, a hand-owned file states that requirement itself with [module: go.GoRequiresUnsafe], which the emission unions into <AllowUnsafeBlocks>.

The pointer half needs nothing extra: these wrappers pass addresses as uintptr, and golib’s ж<T>uintptr operator pins the managed storage and yields a real address rather than a token, so the kernel reads and writes through it. Go’s second result r2 is reproduced exactly rather than approximated — the x86-64 syscall convention clobbers only RCX/R11, so the RDX the assembly reports is the a3 that went in. That, the SysV variadic question, and errno were each measured rather than assumed, and the one case libc cannot distinguish is disclosed in the file. Full detail, including the scheduler brackets that are a faithful no-op and why runtime.argslice must be populated in the same change that forwards it.

Full detail: Reference → Manually-Converted Declarations — every hand-owned surface in full: the guintptr family, sync/atomic.Value, the reflection bridge, whitelisted //go:linkname forwarders in both directions (a PULL binds another package’s symbol; a PUSH takes another package’s body, or announces the pair it cannot honor), realizing an asm-backed arch layer with managed hardware intrinsics, realizing the runtime timer contract, the runtime’s process-control surface, and sync.Pool’s managed ring slot and thread-affine shard index.


The standard library reproduces Go -tags purego

The converted standard library corpus reproduces Go built with -tags purego, not the default amd64/arm64 build. Go implements hot crypto/hash functions in .s assembly the transpiler cannot convert (the Go file has only a bodyless declaration gated … && !purego), so a default build turns them into throwing stubs that compile but can’t run; purego selects the portable pure-Go variants with real bodies. -stdlib and -tests apply -tags purego by default (an explicit -tags replaces it, -tags= clears it) and print the effective tags at the start of each run — a -tests run reconverts the package’s production sources, so it must reproduce the same emission. Every other conversion is tag-neutral. purego is a convention, not a language rule, so the default set carries every portable-fallback tag the stdlib actually uses: math/big predates purego and spells its own math_big_pure_go, gating arith_decl_pure.go (real pure-Go forwarders) against arith_decl.go’s eight bodyless arith_$GOARCH.s declarations — without it every big.Int/Float/Rat arithmetic path compiled clean and threw on first use. Asm-backed declarations split three ways: purego-gated (the tag gives a real body — the common case, crypto/sha256 et al.), GOARCH-gated with no purego escape (hand-owned, e.g. internal/chacha8rand and hash/crc32 — whose crc32_amd64.go carries no build line at all, so purego selects it too), and genuinely raw-metal ([module: GoManualConversion] compiling stub). Hand-owning the second bucket need not mean stubbing: where .NET exposes the same instructions the .s file issues, the arch layer can be ported for real — hash/crc32 runs on Sse42.Crc32 and Pclmulqdq intrinsics. One accepted behavioral divergence from the default build: under purego, crypto/elliptic P256 Inverse panics — exactly as real Go does under -tags purego (an upstream gating inconsistency), so matching it is fidelity.

Full detail: Reference → The standard-library conversion applies -tags purego — the exposure decision and rejected alternatives, the three-bucket taxonomy, and the verified crypto/elliptic divergence.


Deterministic Output

Converter output is byte-reproducible: the same Go source with the same converter build produces byte-identical C# every run — a guarantee the goldens, the corpus build gate, and any release tag all rest on. It’s enforced by converting files sequentially in sorted-filename order, a deterministic dependency-complete stdlib queue, and sorted emission of any set-backed output.

Full detail: Reference → Deterministic Output.


This summary tracks the technical reference — when a conversion decision changes the headline mapping of a construct, update the matching section here (with a real example); record the full detail in the reference. See ../CLAUDE.md, “Record the conversion decision.”