Issue 356: Struct Update Syntax (..old)
Status: implemented
Date: 2026-02-19
Implemented: 2026-02-19
Issue: https://github.com/johnynek/bosatsu/issues/356
Implementation status: all items in this design were implemented.
Goal
Add Rust-style struct update syntax:
struct Foo(a: Int, b: Int, c: Int)
oldFoo = Foo(1, 2, 3)
newA = 12
foo1 = Foo { a: newA, ..oldFoo }
with source-level desugaring in SourceConverter, no new core runtime features, and clear rejection rules for non-struct-like constructors.
Assessment of the Current Plan
The current assessment is mostly correct:
Declaration/parser changes are required first, because current syntax only supports record fields (field/field: expr) and has no..baseform.SourceConverteris the right place for desugaring, because it already has constructor/type metadata and already lowers record constructors.- The key eligibility check should be based on constructor-to-type lookup and confirming the type has exactly one constructor.
Two important caveats:
SourceConvertermust usegetConstructor(not onlygetConstructorParams) so it can inspect the owningDefinedTypeand its full constructor list.- For eligible updates, desugar to a single-branch
match(no fallback_branch), otherwise totality checking may report the fallback as unreachable for single-constructor types.
Non-goals
- Supporting updates for enums/types with multiple constructors.
- Changing general record-constructor semantics (
Foo { a: 1 }) beyond update-specific behavior. - Changing pattern syntax (this is expression syntax only).
- Introducing mutable update semantics; this remains pure reconstruction.
Proposed Syntax and Semantics
Syntax (V1)
Allow .. update source inside record constructor braces:
Foo { a: newA, ..oldFoo }
Foo { a, ..oldFoo } # shorthand field
Foo { ..oldFoo } # parses, but rejected during SourceConverter (Ior.Both)
Constraints:
- At most one
..baseper constructor. ..basemust appear last in the brace list...in expressions must always be followed by a source expression (..baseExpr); bare..is invalid.- Optional whitespace after
..is allowed (..xand.. xare equivalent).
Rejected examples:
Foo { ..x, a: 1 } # spread not last
Foo { ..x, ..y } # multiple spreads
Foo { .. } # missing source expression
Relation to existing ... pattern syntax
Bosatsu already uses ... in pattern matching for partial constructor patterns, for example:
match v:
case Foo { a, ... }:
...
This proposal adds expression syntax with ..:
next = Foo { a: 1, ..oldFoo }
Disambiguation rules:
- Pattern context uses
Pattern.matchParser; there...keeps its existing meaning (“ignore remaining fields/args”). - Expression context uses
Declaration.recordConstructorP; there we parse.. <nonbinding-expr>as update source. - Token shape is distinct by design:
...(three dots) is pattern-partial syntax...expr(two dots plus expression) is expression-update syntax.- In expression record constructors, a literal
...is rejected.
Eligibility Rule (semantic)
Foo { ..., ..base } is valid only if constructor Foo belongs to a type with exactly one constructor.
Implementation check in SourceConverter:
- Resolve
(package, constructor)via existingnameToCons. - Lookup
env.getConstructor(package, constructor)to get(definedType, constructorFn). - Require
definedType.constructors.lengthCompare(1) == 0.
If not single-constructor, emit a dedicated source-converter error.
Why V1 does not support multi-constructor enums
It is possible to define a desugaring for enum types with more than one constructor. For:
Foo { a: 1, ..bar }
we could lower to:
match bar:
case Foo(_, bar_b, ...):
Foo(1, bar_b, ...)
case _:
bar
So this is technically feasible.
V1 intentionally does not support this form for multi-constructor enums for these reasons:
- Hidden control flow: syntax that looks like direct construction would actually be conditional matching.
- Silent no-op risk: when
baris another variant, the update does nothing and returnsbar. - Reduced readability/reviewability: readers must remember the implicit fallback path to understand behavior.
Because these are high-cost semantic surprises, V1 restricts update syntax to single-constructor types only.
Tuple interaction
Tuples participate via their existing Predef constructor types:
TupleNis defined as a single-constructor struct.- Its fields are named
item1,item2, …,itemN.
So tuple update uses the same record-constructor update form, for example:
updated = Tuple2 { item1: a, ..tup }
There is no special tuple-literal update syntax in V1:
Tuple2 { item1: a, ..tup }is supported (subject to the same semantic checks).- Syntax like
(a, ..tup)is out of scope for this issue.
Update Rewrite
Given:
Foo { <explicit fields>, ..baseExpr }
with constructor parameter order p1, p2, ..., pn, rewrite to:
match baseExpr:
case Foo(<pat1>, <pat2>, ..., <patn>):
Foo(<arg1>, <arg2>, ..., <argn>)
where for each parameter pi:
- If
piwas explicitly set in the update: pati = _argi = <explicit expression for pi>- If
piwas omitted: pati = freshVar_iargi = freshVar_i
No fallback branch is emitted.
Required non-trivial update rules
To avoid no-op syntax and fully-explicit rebuilds, require both:
- At least one field sourced from
baseExpr(at least one omitted parameter). - At least one explicit field override.
If either condition fails, emit a SourceConverter error as Ior.Both and keep converting so additional errors can still be accumulated.
Example rejected:
struct Foo(a: Int)
Foo { a: 3, ..oldFoo } # no field actually taken from oldFoo
Foo { ..oldFoo } # no explicit field override
Duplicate explicit field policy
If the same field appears multiple times in update fields:
- Keep the last written value for lowering (deterministic “last write wins”).
- Emit a SourceConverter error as
Ior.Both. - Continue conversion to accumulate other diagnostics.
Example:
Foo { a: 1, a: 2, ..x }
lowers as if a: 2 was used, but still reports the duplicate-field error.
Defaults Interaction
For update syntax with ..base, omitted fields come from base, not constructor defaults.
Defaults remain relevant only for existing non-update record construction (Foo { ... } without ..base).
AST and Parser Changes
Declaration.scala
Update RecordConstructor shape to carry optional update source, e.g.:
case class RecordConstructor(
cons: Constructor,
args: List[RecordArg],
updateFrom: Option[NonBinding]
)
and update all traversals/utilities:
toDocprinter (Foo { fields..., ..expr }formatting).freeVars/allNames.replaceRegions.substitute.
RecordArg parsing
Keep existing field arg kinds:
Simple(field)Pair(field, expr)
and add parsing for optional trailing ..decl in record-constructor braces.
Parser note:
- The record-constructor parser should parse an optional trailing spread clause separately from field args (do not treat
..as aRecordArg). - Within expression parsing, attempt
...first as an explicit rejection path (or forbid it directly) so users get a clear error message pointing to pattern-only syntax. - Spread clause parsing should accept optional spaces after
..before the source expression.
Pattern conversion safeguard
Declaration.toPattern must reject record constructors that contain updateFrom (None result), since update syntax is expression-only.
SourceConverter Changes
fromDecl record-constructor branch
Split behavior:
updateFrom = None: keep current behavior (including default filling).updateFrom = Some(baseExpr): apply update rewrite path.
Update rewrite algorithm details
- Convert explicit field args to a mapping exactly as today (
Simpleresolves as variable expression;Pairuses explicit expression). - Detect duplicate explicit fields:
- Add error (
Ior.Both) when duplicates are present. - Keep the last explicit value per field for the mapping.
- Validate unexpected fields exactly as today.
- Resolve constructor metadata with
env.getConstructor. - Enforce single-constructor type.
- Compute omitted parameters:
omitted = params.filterNot(p => mapping.contains(p.name))- If
mapping.isEmpty, emit dedicated error asIor.Both(“update has no explicit field overrides”). - If
omitted.isEmpty, emit dedicated error asIor.Both(“update uses no fields from base”). - Generate fresh bindables for omitted params using existing synthetic-name strategy (
unusedNames). - Build pattern args in declared parameter order:
- explicit ->
Pattern.WildCard - omitted ->
Pattern.Var(fresh) - Build constructor body args in same order:
- explicit -> mapped explicit expression
- omitted ->
Expr.Local(fresh, tag) - Convert
baseExpronce vialoop(baseExpr)and use it as match scrutinee. - Emit single-branch
Expr.Match(scrutinee, Branch(PositionalStruct(...), None, rebuiltCtor), tag).
This guarantees single evaluation of baseExpr and no accidental double evaluation.
New SourceConverter Errors
Add explicit errors for update syntax failures:
RecordUpdateRequiresSingleConstructorRecordUpdateNoFieldsFromBaseRecordUpdateRequiresExplicitFieldRecordUpdateDuplicateField
with regions on the record constructor expression.
Message guidance:
- For multi-constructor types, include constructor/type context and explain update syntax is limited to single-constructor types.
- For zero-use-of-base, explain all fields were explicitly set and suggest removing
..baseor omitting at least one field. - For zero-explicit-fields, explain this is an identity update and suggest using the source expression directly.
- For duplicates, list duplicated field(s) and explain last explicit value is used for continued checking.
PackageError.SourceConverterErrorsIn needs no structural changes; new errors flow through existing rendering.
Why this design is safe
- Pure source-level desugaring: no new IR/proto/runtime representation needed.
- Constructor field order and names come from existing type env metadata (same source of truth as current record constructor conversion).
- Totality remains valid because generated match is total under enforced single-constructor eligibility and wildcard/var parameter patterns.
- Name capture is avoided with synthetic fresh variables.
Implementation Plan
- Extend
Declaration.RecordConstructorwith optionalupdateFrom. - Update
RecordConstructorparser to accept optional trailing..expr. - Update declaration printer and traversal helpers (
freeVars,allNames,replaceRegions,substitute,toPatternbehavior). - Add/adjust parser round-trip tests for update syntax and invalid forms.
- Add new
SourceConverter.Errorvariants for update eligibility/usage failures. - Refactor current record-constructor conversion into shared helpers for:
- explicit field mapping
- unexpected-field checks
- constructor metadata lookup.
- Implement update rewrite path in
SourceConverter.fromDecl. - Keep non-update record constructor path unchanged (including defaults behavior).
- Add
SourceConverterTestcases for: - successful update desugaring shape
- single-constructor eligibility failure
- no-fields-from-base failure
- no-explicit-fields failure (
Foo { ..x }) - duplicate explicit fields produce
Ior.Bothand last-write-wins lowering - shorthand fields with update
- unknown/extra fields behavior parity.
- Add
ErrorMessageTestcases for new user-facing error messages. - Run focused test targets (
ParserTest,SourceConverterTest,ErrorMessageTest), then fullcoretest suite.
Test Matrix
- Parser accept:
Foo { a: 1, ..x }Foo { a, ..x }Foo { ..x }- Parser reject:
Foo { ..x, a: 1 }Foo { ..x, ..y }- Conversion success:
- Struct with omitted fields copied from base.
- Generic single-constructor type update.
- Conversion failure:
- Multi-constructor enum update attempt.
- All-fields-explicit plus
..base. - No-explicit-fields (
Foo { ..x }) with partial conversion (Ior.Both). - Duplicate fields with partial conversion (
Ior.Both). - Regression:
- Existing
Foo { ... }constructor defaults behavior unchanged. - Pattern parsing/
toPatternbehavior unchanged except update-form exclusion.
Resolution Notes
- Reject
Foo { ..oldFoo }semantically via SourceConverter error (Ior.Both) while continuing conversion. - Treat duplicate explicit fields as SourceConverter errors (
Ior.Both) with deterministic last-write-wins lowering.