Skip to main content

Phase 5. Sum types + pattern matching

FieldValue
MEPMEP-54
StatusLANDED
Started2026-05-28 (GMT+7)
Landed2026-05-28 (GMT+7)
Tracking PR#22522
Commit52e07d4998

Gate

20 new fixtures cover the sum-type surface across four axes: basic shape (sum_basic, sum_int_field, sum_two_arms, sum_three_variants), field types (sum_bool_field, sum_string_field, sum_mixed_field_types, sum_two_fields), variants (sum_no_fields, sum_two_unions), match (sum_default_arm, sum_eq_self, sum_match_print_sum, sum_nested_call, sum_use_binding, sum_arith_arm), state (sum_let_var, sum_var_assign, sum_in_if, sum_loop_match). Run: go test ./transpiler3/go/build/... -run TestPhase1Hello/sum_.

Lowering decisions

Mochi type T = A {...} | B {...} lowers to a single Go struct per union: type T struct { Tag uint8; A_Field1 int64; A_Field2 string; B_Field1 bool; ... }. The Tag discriminates variants (assigned in declaration order starting from 0); per-variant fields are flattened with a Variant_Field prefix so all variants share one struct. One constructor T_A(args...) T and T_B(args...) T is emitted per variant, taking the variant's source-order arguments and returning a T with the right Tag and Variant_* fields populated.

VariantLit lowers to a call to the constructor: T_A(1, "x"). VariantFieldAccess lowers to a SelectorExpr on the flattened name: tmp.A_Field1. UnionVarRef is plumbed through lowerExpr for uses outside MatchStmt.

MatchStmt lowers to a Go switch on tmp.Tag: tmp := target; switch tmp.Tag { case 0: <A arm>; case 1: <B arm> }. Each arm establishes a per-binding local: b := tmp.A_Field1 so the arm body can reference b without going through the discriminated struct. The tmp local is generated by freshName("__m") so nested matches do not collide. The default arm (when present) lowers to default: on the same switch.

Choosing the flattened-struct encoding over Go's interface-based sum (one interface + per-variant struct type implementing it) was a deliberate Phase 5 decision: the flattened form has zero allocation overhead per VariantLit (no boxing), and the Tag discriminator is a single byte that fits in the struct alignment slack. The downside is one struct that holds fields for every variant; with the all-scalar-field constraint inherited from Phase 4, the struct stays small (a few words).

TypeUnion is handled in letTypeText so var x T = T_A(...) gets the right annotation.

Files changed

FilePurpose
transpiler3/go/lower/lower.golowerUnionDecl -> struct + Tag + variant ctors
transpiler3/go/lower/expr.goVariantLit, VariantFieldAccess, UnionVarRef
transpiler3/go/lower/stmt.goMatchStmt -> tmp := target; switch tmp.Tag { case N: <arm> }
transpiler3/go/lower/types.goTypeUnion in letTypeText
transpiler3/go/lower/names.gofreshName("__m") for per-match temp locals
tests/transpiler3/go/fixtures/sum_*/20 fixtures

Test set

  • 20 TestPhase1Hello/sum_* subtests covering basic shape, field types, variants, match arms, and state mutations.

Closeout notes

The flattened struct encoding was the right call: every Phase 5 fixture compiles to a single allocation per VariantLit and zero allocations per match. Encoding the variant Tag as uint8 rather than int saves a word on every union value with up to 255 variants (the upstream type-checker caps this); switching to int16 later would be backward-compatible because the Go switch arms compare against integer literals.