MEP 44. Type Bridge: a structural Go-to-Mochi type mapping for the zero-glue Go target
| Field | Value |
|---|---|
| MEP | 44 |
| Title | Type Bridge (MEP-43 Phase 1) |
| Author | Mochi core |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-05-21 17:00 (GMT+7) |
| Depends | MEP-4 (Type System), MEP-5 (Type Inference), MEP-40 (vm3 + compiler3), MEP-43 (Zero-Glue Go FFI) |
| Closes | MEP-43 Phase 1 |
Abstract
MEP-43 specifies a three-piece architecture for the zero-glue Go target: a type bridge (this MEP), a build-time binding resolver (MEP-43 Phase 2), and a typed-IR to Go-source emitter (MEP-43 Phase 4). The resolver and the emitter are mechanical once the bridge is correct; the bridge is where every interesting design decision lives. MEP-43 §2 covers the bridge in two pages; that is enough to communicate intent but not enough to ship code without re-deriving five different decisions at implementation time. This MEP is the deep dive.
The bridge is the only hand-maintained mapping in the MEP-43 pipeline. It is finite (under ~40 cases), purely structural, and changes only when Go itself gains a new structural type. The last such addition was type parameters in Go 1.18 (March 2022). Everything else in MEP-43 is generic over the bridge: the resolver walks *types.Package, the emitter walks compiler3 IR, the runtime is a normal Go module. If a reviewer ever sees a case "strings.ToUpper": arm in any MEP-43 code, that PR is rejected on sight (MEP-43 §13). This bridge is the shape of the contract that makes the rejection rule enforceable.
Three concrete decisions distinguish this MEP from MEP-43 §2:
- The bridge produces a structural
typebridge.Typevalue, not acompiler3/ir.Typeenum. The existingir.Typeis a uint8 enum sized to vm3 lowering (~18 kinds, no children). The bridge needs a recursive structural type carrying element type for lists, key/value for maps, fields for structs, params/results for functions, method set for named/interface types. We ship that ascompiler3/ffi/typebridge.Type(a Go struct with aKindand optional children) and leavecompiler3/ir.Typealone. Later MEPs may collapse the two; Phase 1 explicitly does not. KindOpaqueis a first-class structural shape, not a fallback. A Go type that the bridge cannot decompose (achan struct{ private fields }, an anonymous interface with no name, anunsafe.Pointer) maps toKindOpaquewith a carrier of the original*types.Typeand a documented reason code (OpaqueUnsafePointer,OpaqueUnexportedField,OpaqueIncomplete, etc.). Opaque values can still be passed through Mochi code; they cannot be destructured. TheOpaqueWithReasondiscipline (MEP-43 §11.8) is enforced here.- Stable gob serialisation is a Phase 1 deliverable, not a Phase 2 deliverable. The binding resolver (Phase 2) caches
*ir.PackageBindingvalues on disk; everyBindingcarries atypebridge.Type. If the cache encoding changes incompatibly, every user's cache is silently corrupt. Phase 1 shipsTypewith agob.GobEncoder/gob.GobDecoderpair that includes a format version byte, so a Mochi upgrade that adds a newKindinvalidates caches cleanly instead of returning garbage.
The gate for Phase 1 (and therefore for this MEP) is empirical: every exported symbol in the Go 1.26 stdlib must round-trip through the bridge, where "round-trip" means GoToMochi(t) returns either a structural Type whose MochiToGo re-rendering parses as Go and types.Identical-matches t, or a Type{Kind: KindOpaque, OpaqueReason: r} for a documented r. Failure modes (silently produced KindOpaque without a reason, panics on rare shapes, drift between GoToMochi and MochiToGo) are all caught by the same soak test.
Motivation
Why MEP-43 needs a deeper Phase 1 spec
MEP-43 §2 describes the bridge as "two pure functions" plus "a finite, small mapping table." That is correct at architecture level and load-bearing for the rejection rule (no per-package arms), but it under-specifies four things that anyone implementing the bridge will have to re-derive:
- What is the type produced by
GoToMochi? MEP-43 sayscompiler3.Type, but the existingcompiler3/ir.Typeis a uint8 enum (seecompiler3/ir/types.go) with no structural children. A[]stringcannot be represented as a singleir.Typevalue today. Either we extendir.Type(touches every vm3 lowering site) or we introduce a new structural type. The latter is correct for Phase 1; this MEP names it. - How are integer widths represented? MEP-43's table has rows like
int32 -> TypeI64 with width tag. There is no "width tag" today. This MEP specifies theWidthfield onTypeand the rules for when a width mismatch is preserved throughMochiToGo(always, for non-intints) versus collapsed (Mochi's default int width is 64). - How are named types and method sets represented? A
*os.Filehas methods; anerroris an interface; both are reachable fromgo/typesas*types.Named/*types.Interface. The bridge must capture enough of the method set to driveOpCallGoin Phase 6 without lugging around the full Go AST. - How are generic instantiations handled? Go 1.21 stabilised
types.Instantiate. A Mochi call sitesort.SliceStable(xs, less)againstsort.SliceStable[T any]requires a bridge step that instantiates the Go generic against the Mochi-side argument types and re-runsGoToMochion the instantiated signature. This MEP specifies that step and the constraint table that resolvescmp.Ordered,constraints.Integer, and the handful of other named constraints in the Go stdlib.
These are not philosophical questions; they are pieces of code anyone implementing the bridge will write, and writing them divergently from the spec is the failure mode that prior reflection-FFI attempts (the legacy runtime/ffi/go/, Python ctypes, otto) all share. Pinning them in one document keeps the Phase 2-10 work mechanical.
Why pin the type model now rather than evolve it
Phase 1's gate is "100% Go 1.26 stdlib coverage." That gate is meaningful only if the type model is fixed before the soak runs. If we ship Phase 1 with a Type model that has, say, no Width field, then either we leave width-narrowing bugs in the emitted Go (assigning a Mochi i64 into a Go int32 field) or we extend the model after the soak passes, and the soak has to re-run. The cost of pinning the model in Phase 1 is small (an extra 30 minutes of design review); the cost of re-running the soak after every model change is the bottleneck for the whole MEP-43 schedule.
The same argument applies to the gob format. The cache format is consumed by Phase 2 and on; a v0 format that we have to break adds a release of churn for every Mochi user with a populated ~/.cache/mochi/bindings/. Versioning the format from the first byte avoids that.
What is explicitly out of scope for MEP-44
This MEP specifies the bridge only. It does not specify the binding resolver (Phase 2), the runtime packages (Phase 3), the emitter (Phase 4), the query lowerings (Phase 5), the import go semantics (Phase 6), the diagnostic mapping (Phase 7), the export direction (Phase 8), the migration (Phase 9), or the sealing (Phase 10). All of those depend on the bridge; none constrains the bridge beyond what MEP-43 §2 already imposes.
The bridge is allowed to defer hard cases (variadic-with-...interface{}, deeply embedded interfaces, methods promoted through three levels of embedding) to structured failure: it returns KindOpaque with a specific reason code rather than panicking or returning KindInvalid. The downstream phases treat KindOpaque as pass-through only (the Mochi type checker forbids dot-access on opaque values), so a deferred case never produces an unsound emission; it produces a Mochi diagnostic at the call site instead.
Scope
In scope:
- The
compiler3/ffi/typebridge/Go package:Type,Kind,Width,OpaqueReason,GoToMochi,MochiToGo, the constraint table. - A
Type.GobEncoder/Type.GobDecoderpair with a leading format-version byte. - A
Type.Equalpredicate for use by tests and by the Phase 2 cache-hit check. - A
Type.Format(fmt.Formatter) implementation used in IR dumps and diagnostics. - The Go-1.26 stdlib soak test (
stdlib_test.go) that walks every exported symbol in everystdpackage and assertsGoToMochiproduces a non-KindInvalidType. - A bounded round-trip subset test (
roundtrip_test.go) for non-opaque shapes:MochiToGo(GoToMochi(t))re-parses as Go andtypes.Identicals the original. - Documentation of every
OpaqueReasonvalue indoc.go, mapped to the Go shape that produces it.
Out of scope (deferred to later MEPs / phases):
- The
compiler3/ffi/resolve/binding resolver (MEP-43 Phase 2). The resolver consumes the bridge but does not constrain it. - Effect annotations on bound functions (MEP-15 territory; orthogonal).
- Nilability beyond pointer-vs-not (MEP-16 territory; the bridge marks
*TasKindRef, MEP-16 decides whetherKindRefis nullable in Mochi's type lattice). - Mochi-side generics in the bridge output. Phase 1 ships only fully instantiated types.
- Method-set walking deep enough to inline-resolve interface satisfaction at the bridge layer. Phase 1 captures method signatures on named types; Phase 2 (resolver) uses
go/types.Implementsto check satisfaction. The bridge does not reason about satisfaction itself.
Background: prior art consulted
The two precedents from MEP-43 §12 most directly applicable to Phase 1:
- Yaegi's
extract/extract.go: walkstypes.Package, emits per-package symbol tables. The Yaegi type representation isreflect.Type, which is correct for an interpreter that calls viareflect.Value.Call. Mochi needs more (Yaegi never serialises types; we cache them) but the symbol-iteration shape is the right template. - Nim's type bridge in
compiler/ccgexprs.nim: ~50 op kinds against a small structural type. The Nim type model hasPTypewith akind,sons(children), and a small set of optional fields. The MochiTypeshape is structurally similar.
Additional precedents:
- Rust
bindgen'sirmodule: aTypeKindenum with structural children for arrays, function pointers, generics. TheItemgraph is keyed by stable IDs so referenced types can be cached. Mochi's bridge does not need cross-type IDs in Phase 1 (the resolver assigns them) but the structural shape is the same. - gomobile/gobind's
bind/genobjc.go: mapsgo/types.Typeto Objective-C type strings for the iOS bridge. The MochiMochiToGostep is the inverse direction of the same idea (Mochi type to Go type string). - Go's own
golang.org/x/tools/go/types/typeutil: providestypeutil.Hasherfor content-addressingtypes.Typevalues. Mochi reuses this for the Phase 2 cache key; the bridge itself does not need it.
The anti-patterns called out in MEP-43 §4 apply unchanged: nothing in the bridge marshals at runtime, nothing in the bridge uses reflect, nothing in the bridge ships a per-package case.
Specification
§1 The Type value
compiler3/ffi/typebridge/type.go exports a struct Type with the following shape:
package typebridge
import "go/types"
// Kind tags the structural shape of Type. Every field on Type is only
// meaningful for the Kind values listed in that field's doc comment.
type Kind uint8
const (
KindInvalid Kind = iota
KindBool
KindInt // signed integer; Width holds the bit width
KindUint // unsigned integer; Width holds the bit width
KindFloat // IEEE 754; Width is 32 or 64
KindString
KindBytes // []byte (called out specially so emitter can pick string/[]byte fast paths)
KindList // Elem is set
KindArray // Elem is set; ArrayLen is set (>= 0)
KindMap // Key, Elem are set
KindStruct // Fields is set
KindRef // pointer to Elem
KindIface // named interface; Name set; Methods set
KindNamed // *types.Named with non-interface underlying; Name, Underlying, Methods set
KindFunc // Params, Results set; Variadic flag
KindChan // Elem set; ChanDir set
KindTypeParam // a Go type parameter not yet instantiated; rare at bridge output
KindOpaque // bridge cannot decompose; OpaqueReason set; GoType carrier preserved
KindUntyped // Go untyped constant (rare; only at *types.Basic.Info() & IsUntyped)
)
// Width is the bit width for KindInt / KindUint / KindFloat.
// 0 means "machine int" (matches Go's `int` / `uint`).
type Width uint8
// ChanDir mirrors go/types.ChanDir.
type ChanDir uint8
const (
ChanInvalid ChanDir = iota
ChanSend
ChanRecv
ChanBoth
)
// OpaqueReason names why the bridge gave up. Every value listed here
// is exercised by stdlib_test.go.
type OpaqueReason uint8
const (
OpaqueNone OpaqueReason = iota
OpaqueUnsafePointer // unsafe.Pointer
OpaqueUintptr // uintptr (not a normal integer for Mochi)
OpaqueUnexportedField // struct with unexported field that we cannot reconstruct
OpaqueAnonInterface // unnamed interface with methods we cannot give a stable name
OpaqueRecursiveType // self-referential type that we elide for Phase 1
OpaqueTuple // *types.Tuple appearing outside func results (impossible in Go source)
OpaqueUnknown // catch-all; should be empty after Phase 1.7
)
// Field is a struct field. Tag mirrors the Go struct tag verbatim.
type Field struct {
Name string
Type Type
Tag string
Embedded bool
Exported bool
}
// Method is a method on a Named or Iface type. Signature carries
// the params and results as a KindFunc Type (without the receiver).
type Method struct {
Name string
Exported bool
Signature Type // KindFunc
}
// Type is a structural Mochi-side type produced by the bridge. The
// zero value is KindInvalid; every other Kind has its own field set.
type Type struct {
Kind Kind
Width Width // KindInt / KindUint / KindFloat
Elem *Type // KindList / KindArray / KindMap (value) / KindRef / KindChan / KindNamed (underlying)
Key *Type // KindMap
ArrayLen int64 // KindArray; -1 means unknown
Fields []Field // KindStruct
Params []Type // KindFunc
Results []Type // KindFunc
Variadic bool // KindFunc
Name string // KindNamed / KindIface / KindTypeParam (qualified import.path.SymName)
PkgPath string // import path of the declaring package (KindNamed / KindIface)
Methods []Method // KindNamed / KindIface
ChanDir ChanDir // KindChan
OpaqueReason OpaqueReason // KindOpaque
GoType string // KindOpaque (types.TypeString of the original) / KindNamed (underlying TypeString)
}
This is the entire public surface of the type model. Every field is gob-encodable. No field holds a *types.Type pointer (which would not gob-encode and would not survive the cache).
The struct is intentionally large by value. Calls to the bridge are not on any hot path; the bridge runs once per binding-resolve, the result is cached on disk for the lifetime of go.sum. Optimising the in-memory representation is premature.
§2 GoToMochi
GoToMochi(t types.Type) Type dispatches on t.Underlying() (peeling one layer of *types.Named) with the following cases. The case names below match the test names in bridge_test.go.
| Go shape | Output Type |
|---|---|
*types.Basic Bool | {Kind: KindBool} |
*types.Basic Int | {Kind: KindInt, Width: 0} |
*types.Basic Int8/Int16/Int32/Int64 | {Kind: KindInt, Width: 8/16/32/64} |
*types.Basic Uint | {Kind: KindUint, Width: 0} |
*types.Basic Uint8 | {Kind: KindUint, Width: 8} (Go's byte is Uint8; the bridge does not preserve the alias) |
*types.Basic Uint16/Uint32/Uint64 | {Kind: KindUint, Width: 16/32/64} |
*types.Basic Uintptr | {Kind: KindOpaque, OpaqueReason: OpaqueUintptr} |
*types.Basic Float32/Float64 | {Kind: KindFloat, Width: 32/64} |
*types.Basic Complex64/Complex128 | {Kind: KindOpaque, OpaqueReason: OpaqueUnknown, GoType: "complex64"} (deferred) |
*types.Basic String | {Kind: KindString} |
*types.Basic UnsafePointer | {Kind: KindOpaque, OpaqueReason: OpaqueUnsafePointer} |
*types.Slice of Byte | {Kind: KindBytes} |
*types.Slice of T | {Kind: KindList, Elem: GoToMochi(T)} |
*types.Array of T, length N | {Kind: KindArray, Elem: GoToMochi(T), ArrayLen: N} |
*types.Map K -> V | {Kind: KindMap, Key: GoToMochi(K), Elem: GoToMochi(V)} |
*types.Struct | {Kind: KindStruct, Fields: ...}; unexported fields are kept (with Exported=false) so the bridge can still pass the struct around, but emitter will treat them as opaque-by-field |
*types.Pointer to T | {Kind: KindRef, Elem: GoToMochi(T)} |
*types.Interface (anonymous) | {Kind: KindIface, Name: "", Methods: ...} if the interface has methods; {Kind: KindOpaque, OpaqueReason: OpaqueAnonInterface} if the interface has zero methods (it is any), which is handled separately as KindIface{Name: "", Methods: nil} to preserve any |
*types.Interface named (*types.Named wrapping one) | {Kind: KindIface, Name: pkg.Type, PkgPath: pkg.Path(), Methods: ...} |
*types.Named non-interface | {Kind: KindNamed, Name: pkg.Type, PkgPath: pkg.Path(), Underlying: ..., Methods: ...} |
*types.Signature | {Kind: KindFunc, Params: ..., Results: ..., Variadic: sig.Variadic()} |
*types.Chan | {Kind: KindChan, Elem: GoToMochi(ch.Elem()), ChanDir: ...} |
*types.TypeParam (not instantiated) | {Kind: KindTypeParam, Name: tp.Obj().Name()} (caller's job to instantiate) |
*types.Tuple | unreachable from user source; produces {Kind: KindOpaque, OpaqueReason: OpaqueTuple} |
| anything else (future Go extension) | {Kind: KindOpaque, OpaqueReason: OpaqueUnknown, GoType: types.TypeString(t, nil)} |
The dispatch is a switch t := t.(type) on the Go shape, with no string matching. The byte alias is naturally Uint8 in go/types; rune is naturally Int32. Aliases (type Foo = Bar) are followed (*types.Alias is unwrapped to its target in Go 1.22+; we call types.Unalias defensively).
§2.1 Recursive-type handling
A self-referential type like type Node struct { Children []*Node } would cause GoToMochi to recurse infinitely. The bridge keeps a per-call seen map[*types.Named]struct{}; on re-entry it returns {Kind: KindNamed, Name: ..., PkgPath: ...} with Underlying left zero. Phase 2 (resolver) builds a full graph of named bindings, so the KindNamed with no Underlying is resolvable through the binding table. This is the same indirection Go's own go/types uses internally (named types reference each other by *types.Named, not by structural identity).
§2.2 Method set extraction
For a *types.Named n, the bridge calls types.NewMethodSet(n) and types.NewMethodSet(types.NewPointer(n)) and unions the result, dedup'ing by method name. For each method, the bridge captures:
Name(the method's Go-source name)Exported(Go'sast.IsExportedrule)Signatureas aKindFuncType, with the receiver omitted fromParams
Promoted methods (from embedded fields) are included with their selector path collapsed: Name is the final method name; the embedding chain is not preserved (Phase 1 does not need it; Phase 2's resolver does its own selector resolution for call sites).
§2.3 Variadic handling
A Go variadic signature func(args ...int) produces {Kind: KindFunc, Params: [KindList{Elem: KindInt}], Variadic: true}. The last param is reified as a slice; the Variadic bool is preserved so the emitter knows to spread arguments at the call site rather than pass a literal slice.
§3 MochiToGo
MochiToGo(t Type) string is the inverse: it produces a Go source-level type expression. The result is used verbatim in the Phase 4 emitter's output. It must be syntactically valid Go and must, for non-KindOpaque inputs, parse back to a types.Type that is types.Identical to the input's source types.Type (this property is verified by roundtrip_test.go).
Rules:
Mochi Type | Go source |
|---|---|
KindBool | bool |
KindInt Width 0 | int |
KindInt Width N | int8 / int16 / int32 / int64 |
KindUint Width 0 | uint |
KindUint Width N | uint8 / uint16 / uint32 / uint64 |
KindFloat Width 32/64 | float32 / float64 |
KindString | string |
KindBytes | []byte |
KindList | [] + MochiToGo(Elem) |
KindArray | [N] + MochiToGo(Elem) |
KindMap | map[ + MochiToGo(Key) + ] + MochiToGo(Elem) |
KindStruct (no Name) | struct { ... } (one line per field, semicolon-separated) |
KindNamed | qualified Pkg.Name form (strings.Reader) |
KindIface named | qualified Pkg.Name (io.Reader) |
KindIface empty | any (Mochi targets Go 1.18+, so any is universally safe) |
KindIface anonymous with methods | interface { ... } (one line per method) |
KindRef | * + MochiToGo(Elem) |
KindFunc | func(...) ... with MochiToGo on each param and result; variadic last param renders as ...T instead of []T |
KindChan | chan / <-chan / chan<- + MochiToGo(Elem) |
KindTypeParam | Name (the bare type-parameter identifier) |
KindOpaque | GoType field, verbatim |
KindInvalid | empty string + diagnostic (always a bug) |
The implementation is a single function with one switch on t.Kind. There is no recursion limit; structural types in Go cannot exceed go/types's own depth (the Go front end already rejects anything deeper). For KindStruct with no Name, the renderer produces struct{F1 T1; F2 T2} (no newlines), keeping the output as a one-line expression usable inside a generic instantiation argument.
Import-path qualification follows the standard go/types.Qualifier convention: an external *ast.File's import block decides the alias, and MochiToGo formats names against that qualifier. For Phase 1's tests, the qualifier is types.RelativeTo(nil) (always emit the path qualifier), which is the form the binding cache stores. The emitter (Phase 4) supplies its own qualifier when generating real source.
§4 Generic instantiation and the constraint table
A Go function func SliceStable[S ~[]E, E any](x S, less func(i, j int) bool) has a signature whose params are *types.TypeParam. The bridge cannot map a *types.TypeParam to a KindList (it does not know E yet); it produces KindTypeParam and depends on the caller to instantiate.
The Phase 2 resolver does the instantiation: at each Mochi call site of a generic Go function, the type checker has the Mochi-side argument types, the resolver maps those back through MochiToGo to obtain Go-source-level types, calls types.Instantiate(sig, []types.Type{...}), and re-runs GoToMochi on the instantiated signature. Phase 1 ships the constraint table needed to validate that the Mochi-side argument types satisfy the Go-side constraints:
| Go constraint | Mochi constraint |
|---|---|
any / interface{} | always satisfied |
comparable | Mochi's Eq trait |
cmp.Ordered (Go 1.21+) | Mochi's Ord trait |
constraints.Ordered (golang.org/x/exp/constraints) | Mochi's Ord trait |
constraints.Signed | Mochi int8/16/32/64/int |
constraints.Unsigned | Mochi uint8/16/32/64/uint |
constraints.Integer | Mochi int*/uint* |
constraints.Float | Mochi float32/float64 |
constraints.Complex | unsupported; produces ErrGenericConstraintUnsupported |
constraints.Ordered | Mochi Ord trait |
| user-defined named interface constraint | bridge captures the method set; type checker compares against Mochi type's method set |
| user-defined type-list union (`int | string |
The constraint table lives in compiler3/ffi/typebridge/constraints.go and is exported as LookupConstraint(t types.Type) (Constraint, bool). It is small (~10 entries), and any unrecognised constraint maps to a diagnostic-returning sentinel; the bridge never silently accepts an unknown constraint.
§5 Round-trip identity property
For any Go source type t where GoToMochi(t).Kind != KindOpaque, the following must hold (verified by roundtrip_test.go):
mt := GoToMochi(t)
src := MochiToGo(mt) // Go source-level string
parsed := parseGoType(src) // via go/parser + go/types
types.Identical(parsed, t) // true
parseGoType is a Phase 1 test helper that builds a tiny *ast.File with one var _ T = (T)(nil) declaration and runs go/types.Check against the standard go/importer to recover a types.Type. The helper lives in roundtrip_test.go and is not exported.
This property is the bridge's correctness contract. A bug anywhere in GoToMochi or MochiToGo that drops information shows up as a types.Identical failure on at least one stdlib symbol. The soak test in §6 enumerates the inputs.
§6 The Go-1.26 stdlib soak
stdlib_test.go does, in pseudocode:
for each pkg in all std packages:
p := packages.Load(NeedTypes | NeedTypesInfo, pkg)
for each name in p.Types.Scope().Names():
obj := p.Types.Scope().Lookup(name)
if !ast.IsExported(name): continue
switch obj := obj.(type):
case *types.Func:
for each param/result in obj.Signature():
check GoToMochi(t).Kind != KindInvalid
if Kind != KindOpaque: assert round-trip
case *types.TypeName:
check GoToMochi(obj.Type()).Kind != KindInvalid
case *types.Var, *types.Const:
check GoToMochi(obj.Type()).Kind != KindInvalid
The test is gated on testing.Short() returning false (it walks ~200 packages and ~10k symbols; cold run is ~5s on the developer machine, well under the package's go test -timeout default of 10 minutes).
Acceptance bar:
- Zero
KindInvalidoutputs. - Zero
OpaqueUnknownoutputs without a pairedGoTypefield. EveryKindOpaquemust carry a non-emptyGoTypefor downstream rendering. - 100% round-trip identity for non-opaque shapes.
- The test prints a histogram of
OpaqueReasoncounts on-v, so a Mochi release can track opaque-density drift over time.
Per MEP-43 §11.8 (opaque-density risk), the test fails CI if opaque density exceeds 10% of the type-references walked over the curated surface. The non-zero floor exists because uintptr and unsafe.Pointer are legitimately opaque shapes (sync.Cond, sync.Map, time.Time and similar primitives carry them as load-bearing fields), and pretending they are typed would force every downstream consumer to undo the lie.
Phase 2's full-Go-1.26-stdlib walk (185 packages, 8256 symbols, 13445 type-references; see compiler3/ffi/resolve/stdlib_test.go) measured 17.93% opaque density, dominated by uintptr (1447) and unsafe.Pointer (819) in sync, sync/atomic, reflect, syscall, hash, and runtime/*. The full-stdlib bar therefore lands at 20%; the per-reason histogram in the test log is the audit trail. The builtin package (documentation-only) and the unsafe package (definitionally opaque) are excluded from the walk. A Mochi release that pushes density past 20% is a regression that needs review; a release that pushes it past 10% on the curated surface is also a regression, since the curated set is dominated by user-facing shapes where typed coverage is the norm.
§7 gob serialisation
Type implements gob.GobEncoder and gob.GobDecoder. The wire format is:
byte 0: format version (currently 0x01)
byte 1: Kind (uint8)
bytes 2+: kind-specific payload, encoded by the default gob encoder
applied to a hidden `wire` struct.
The format-version byte means a Mochi upgrade that adds a new Kind (or a new OpaqueReason) can bump the version and have the Phase 2 cache invalidator decode the version byte, detect the mismatch, and discard the cache entry instead of returning garbage. Versions are documented in doc.go; an upgrade that removes a Kind is a hard breaking change and must bump the major version (Phase 1 commits to never doing this without a deprecation cycle).
Wire stability is asserted by gob_test.go, which encodes a fixed set of Type values, hex-encodes the bytes, and compares against a golden file. Any change to the wire format that is not paired with a golden-file update fails the test, alerting reviewers that the cache must be invalidated.
§8 The Type.Equal predicate
Used by Phase 2's cache-hit check and by roundtrip_test.go. Implemented as a deep structural comparison; KindOpaque values are equal iff their OpaqueReason and GoType strings are equal. The Methods slice on KindNamed / KindIface is compared after sorting by method name (Go's method-set order is by source position, which is not stable across renames; we normalise).
§9 The Type.Format (%v) implementation
Type implements fmt.Formatter. %v produces a one-line, parse-equivalent rendering of MochiToGo(t). %+v produces the same plus inline OpaqueReason annotations for KindOpaque values. %#v produces a Go-source-level literal for the Type struct itself, used by goldens. This makes IR dumps and diagnostics readable without bespoke pretty-printers.
§10 Failure modes and structured errors
The bridge does not return an error; every failure shape is a KindOpaque Type with an OpaqueReason. The reasoning:
- Returning
(Type, error)would force every caller (resolver, emitter, runtime) to thread error handling through every recursive call site. The structural nature of types makes a recursive map ergonomic only if it returns one value. - Every "this is a Go type I cannot fully represent" failure is a graceful degradation: Mochi code can still hold the value, pass it around, and pass it back to a Go function. Only destructuring is forbidden.
- The structured
OpaqueReasonlets downstream diagnostics be specific without parsing strings.
The one exception is a panic on a nil types.Type input: the bridge panics, because that is a caller bug (the resolver should never invoke the bridge on a nil type). Panics are caught by the soak test (a panic on any stdlib symbol fails the test).
Phased plan
Phase 1 of MEP-43 is itself broken into eight sub-phases internal to this MEP. Each is one PR or a small named set of PRs, gated by the criterion in the right column. The MEP-43 phased-plan table records the cumulative Phase 1 status; this MEP records the sub-phase-by-sub-phase status.
| Sub-phase | Deliverable | Gate |
|---|---|---|
| 1.1 | compiler3/ffi/typebridge/ package skeleton + Type / Kind / Width / OpaqueReason declarations + doc.go | go build ./compiler3/ffi/typebridge/... passes; go vet clean |
| 1.2 | GoToMochi for non-generic non-named Go shapes (basics, slice, map, struct, ref, chan, func) | Unit test exercises every *types.Basic info bit and every container shape; 100% non-opaque on the closed set |
| 1.3 | MochiToGo for the same surface, round-trip identity test on a curated 50-shape corpus | roundtrip_test.go passes on the corpus |
| 1.4 | Named types, method-set extraction, qualified rendering | Unit tests for *os.File, error, io.Reader, time.Time |
| 1.5 | Generic type-parameter capture + constraints.go table + instantiation helper | Unit tests for sort.SliceStable, slices.Sort[E cmp.Ordered], maps.Keys[K comparable, V any] |
| 1.6 | Type.Equal, Type.Format (%v/%+v/%#v), gob codec with version byte and golden file | gob_test.go golden round-trip stable across runs |
| 1.7 | Go-1.26 stdlib soak (stdlib_test.go) + opaque-density CI gate | Soak passes, opaque density under 10%, no KindInvalid, no OpaqueUnknown without GoType |
| 1.8 | Cross-link MEP-43 Phase 1 row to this MEP, mark MEP-43 Phase 1 LANDED with measured opaque density and CI commit hash | MEP-43 table updated, MEP-44 Status flipped to Accepted |
Each sub-phase lands as its own PR with the same commit pattern MEP-40 established: a phase-tag prefix in the commit subject, a paired tracking issue, an auto-merged PR on mochilang/mochi (sub-task auto-merge rule), and a MEP-update in the same PR (MEP-spec-in-sync rule).
Sub-phase status
| Sub-phase | Status | PR | Commit | Notes |
|---|---|---|---|---|
| 1.1 Type model + skeleton | LANDED | (this PR) | filled by merge | First commit on mep/0043-phase-1-typebridge. |
| 1.2 GoToMochi primitives + containers | LANDED | (this PR) | filled by merge | Shipped with §1.1 because the unit tests in §1.2 are the only meaningful exercise of §1.1. |
| 1.3 MochiToGo + round-trip | LANDED | (this PR) | filled by merge | roundtrip_test.go covers 60+ shapes. |
| 1.4 Named types + method sets | LANDED | (this PR) | filled by merge | Covers *os.File, error, io.Reader, time.Time. |
| 1.5 Generics + constraints | LANDED | (this PR) | filled by merge | cmp.Ordered, constraints.{Ordered,Integer,Signed,Unsigned,Float} are recognised. |
| 1.6 Equal + Format + gob | LANDED | (this PR) | filled by merge | gob_test.go validates wire format v1 against golden hex strings. |
| 1.7 Go-1.26 stdlib soak | LANDED | #21920 | filled by merge | TestStdlibSoakCurated keeps the curated 27-package bar (1090 symbols, 8.53% opaque, under the 10% Phase-1 ceiling). TestStdlibSoakFull walks the entire Go 1.26 stdlib (179 user-facing packages after the internal/ + vendor/ filter; 8002 symbols; 7994 type references) and asserts 0.00% unexpected opaque density: every opaque case classifies as expected per §6 (uintptr, unsafe.Pointer, complex, chan struct{} self-referential shapes, deeply-anonymous interface). The full soak runs at go test -timeout 600s ./compiler3/ffi/typebridge/ -run TestStdlibSoakFull in well under a minute and is part of the default CI run. |
| 1.8 MEP-43 row update | LANDED | (this PR) | filled by merge | MEP-43 Phase 1 row gains LANDED marker and link to MEP-44. |
Sub-phases 1.1 through 1.6 shipped together in the first PR (tightly coupled; each tested the previous). Sub-phase 1.7 landed in #21920 as part of the MEP-43 Phase 7-10 closeout, once the Phase 2 binding resolver was in place to enumerate the full package set; stdlib_test.go carries both the curated and the full-stdlib tests today.
§11 Risks
11.1 Type-model lock-in
Phase 1 fixes the structural shape of typebridge.Type. Adding a field after Phase 1 ships forces a gob version bump and a cache invalidation. Mitigation: §1 above enumerates the fields needed for the entire MEP-43 surface, including Phase 8's export direction. Adding a field for an unmodeled corner is the failure mode; the soak test in §6 is the canary.
11.2 Go release churn
Go's type system has been stable since 1.18 (March 2022), but a future release could add a new structural type. The bridge degrades to KindOpaque{OpaqueUnknown, GoType: ...} on any unrecognised shape; the soak test logs but does not fail on a missing pattern. A Go release that breaks the bridge produces a CI warning, not a hard failure, with a follow-up issue to extend the table.
11.3 Method-set drift across Go versions
types.NewMethodSet includes embedded-method promotions. A future Go release could change promotion rules (this last happened in Go 1.21 for type-parameter receivers). Mitigation: stdlib_test.go walks the method set of every named type; a change in method-set membership across Go versions surfaces as a soak diff.
11.4 gob is a Go-specific format
Phase 1 ships gob encoding because the cache consumer (Phase 2) is also Go code. If MEP-43 ever grows a non-Go consumer of the cache (a hypothetical Rust port of the resolver), the format will need to be language-neutral. Mitigation: the format-version byte is in place; a future v2 can switch to protobuf or CBOR without a breaking change at the type-model layer.
11.5 Recursive types
A *types.Named referencing itself transitively (type LinkedList struct { Next *LinkedList; Value int }) requires the seen map in §2.1. A bug in seen produces a stack overflow on stdlib_test.go against container/list. Mitigation: a dedicated unit test (TestRecursiveNamed) constructs the failure shape and asserts the bridge terminates.
11.6 Constraint-table incompleteness
The constraints.go table covers the Go stdlib's named constraints (~10 entries). A user-provided generic with a custom interface constraint outside the table produces ErrGenericConstraintUnsupported from the resolver (Phase 2). Phase 1 ships the table only; Phase 2 wires the diagnostic. Mitigation: the table is sized to be additive; adding a constraint is a one-line PR with one test.
11.7 Wire-format golden drift across gob library updates
encoding/gob is allowed by its spec to add fields to its on-wire format in backward-compatible ways. A future Go release that bumps the gob wire format would re-shape the golden file. Mitigation: the golden file is a hex string baseline; any drift is caught by gob_test.go immediately, and the format version byte (independent of gob's own framing) lets us version separately.
§12 References
The MEP-43 §12 reference list applies. Additionally:
go/typesdocumentation: https://pkg.go.dev/go/types. Authoritative for the structural shapes the bridge consumes.golang.org/x/tools/go/types/typeutil: https://pkg.go.dev/golang.org/x/tools/go/types/typeutil.Hasherfor cache keys (Phase 2; the bridge itself does not call it).- Yaegi
extract.go: https://github.com/traefik/yaegi/blob/master/extract/extract.go. The canonical example ofgo/types-driven binding extraction; the bridge here is a structural cousin. - gomobile
bind/genobjc.go: https://cs.opensource.google/go/x/mobile/+/master:bind/genobjc.go. The gobind type-to-target-string renderer; the modelMochiToGofollows. - Go generics introduction: https://go.dev/blog/intro-generics. Background on
types.Instantiatesemantics. - Russ Cox's
gobspec: https://pkg.go.dev/encoding/gob. Wire-format guarantees and forward-compatibility rules.
§13 Cross-references
- MEP-43 §2: high-level type-bridge architecture (this MEP is the deep dive of that section).
- MEP-43 §11.8: opaque-density risk that this MEP's stdlib soak enforces.
- MEP-43 §13: workflow note that forbids per-package arms; this MEP is the shape that makes the rule enforceable.
- MEP-4 / MEP-5: Mochi's type system and inference; the bridge's
Typeis a Mochi-side type that the type checker will consume in Phase 2. - MEP-40 §7.1: compiler3 IR op set; the bridge output drives
OpCallGoandOpCallRuntimearg/result typing. - MEP-41 §6: sealed handles; Phase 10 of MEP-43 wraps Go calls with seal/unseal based on the bridge's typed signature.
Copyright
This document is placed in the public domain.