Issue 1676: Default Values in Struct and Enum Constructors
Status: implemented
Date: 2026-02-16
Implemented: 2026-02-17 (PR #1699)
Issue: https://github.com/johnynek/bosatsu/issues/1676
Implementation status: all items in this design were implemented and merged in PR #1699.
Goal
Add default values for constructor fields so record-style construction can omit fields that have defaults, while preserving current language behavior.
Example:
struct Record(
name: Option[String],
uses: Option[String] = None,
with: Option[Record] = None,
run: Option[String] = None,
)
r = Record { name: Some("foo") }
This should typecheck as if missing fields were filled by compiler-provided default expressions.
Explicit V1 Constraints
- Defaults are applied only for record constructor syntax:
C { field: expr }. - Positional constructor calls are unchanged:
C(expr1, expr2, ...)gets no default filling. - Defaults cannot reference constructor parameters.
Rejected example:
struct S(a: Int, b: Int = a) # rejected
Allowed default dependencies are only:
- imported values,
- values defined earlier in the same file,
- earlier default helpers by source order.
If positional constructor defaults are added in the future, that should be designed together with function default arguments in a separate proposal; that is explicitly out of scope for this doc.
Non-goals
- Changing tuple/positional constructor calls (
Record(...)). - Adding named arguments for general function calls.
- Changing pattern matching semantics.
- Making constructor shape changes non-breaking for semver.
Current State
- Type definitions (
Statement.Struct,Statement.EnumBranch) only store(fieldName, optionalType); no default expression slot. - Record construction (
Declaration.RecordConstructorinSourceConverter.fromDecl) requires every constructor field; missing fields produceSourceConverter.MissingArg. - Constructor metadata in
rankn.ConstructorFn/TypedAst.protocontains field names and types only. - API compatibility (
library/ApiDiff.scalaviaLibConfig.validNextVersion) checks constructor shape/name/type/index changes, but has no default-value diff model.
Proposed Semantics
Definition-site syntax
Allow defaults on constructor parameters in both struct and enum constructors:
struct S(a: Int, b: Int = 0)
enum E:
C(x: Int, y: Int = 1)
Construction semantics (record syntax only)
For C { field1: e1, ... }:
- Unknown field names still error.
- Duplicate/extra field behavior remains as today (no new behavior change in this feature).
- For each constructor field in declared order:
- Use provided value if present.
- Else use the field default if one exists.
- Else emit existing
MissingArgerror. - The result still elaborates to ordinary constructor application with all arguments present.
C {}is valid syntax. It typechecks iff all required fields are defaulted; otherwise existing missing-field errors are reported.
Constructor-function calls unchanged
C(...) and first-class constructor values keep current arity/type behavior. Missing positional arguments remain errors.
Pattern matching unchanged
No default filling occurs in patterns. Pattern conversion/totality/match lowering behavior is unchanged.
Elaboration Model
1. Extend constructor metadata
Replace raw (Bindable, Type) argument pairs with a param model carrying optional default metadata.
Suggested shape:
final case class ConstructorParam(
name: Bindable,
tpe: Type,
defaultBinding: Option[Bindable]
)
final case class ConstructorFn[+A](
name: Constructor,
args: List[ConstructorParam],
exists: List[(Type.Var.Bound, A)] = Nil
)
defaultBinding names a compiler-generated helper binding in the defining package.
2. Generation point and scope boundary
Defaults are generated during source-to-core conversion as a conceptual desugaring of the statement stream.
Semantics are:
- Process top-level statements in source order.
- When a
struct/enumdefinition is seen, keep the type definition unchanged. - Immediately after that type definition, insert synthetic non-recursive helper bindings for its defaulted fields.
So yes: semantically these names exist immediately after the defining struct/enum statement.
Consequences:
- Later top-level statements can see/use those helpers (through constructor metadata), earlier ones cannot.
- Default expressions can reference earlier top-level values.
- Defaults may reference earlier defaults from the same type definition under a deterministic order:
struct: parameter order.enum: constructor declaration order, then parameter order within each constructor.- Self-recursive default helpers are not allowed in v1.
3. Generate one helper binding per defaulted field
For each defaulted field, generate a synthetic top-level binding in the defining package using the deterministic API-fingerprint naming rule below.
The helper expression is the parsed default expression and is typechecked against the field type.
This gives:
- Single evaluation/sharing (not re-elaborated per construction site).
- A concrete symbol that downstream packages can reference after import of constructor metadata.
- Normal error reporting at the default expression source region.
4. Helper name allocation and uniqueness
Name allocation happens in SourceConverter (whole-file context available), before defaults are lowered into helper lets.
API-fingerprint naming rule
Each helper name is a deterministic hash of constructor parameter API identity, not of local file context.
Fingerprint input (DefaultNameV1):
- package name,
- type name,
- constructor name,
- parameter index,
- canonical parameter type digest.
Canonical type digest should be computed from a normalized, alpha-stable encoding of the field type (for example de-Bruijn-style bound-variable numbering) so harmless binder renaming does not perturb helper names.
Suggested emitted bindable:
- create
Identifier.synthetic("default$" + <digest>), - where
<digest>is base32/hexBlake3(DefaultNameV1 input).
Example shape:
_default$8f3c1a7d9b42...
Why this satisfies semver-stable naming
Under current ApiDiff policy, changing any of (type/constructor identity, parameter position, parameter type) is already major-only.
So:
- patch/minor changes that keep constructor parameter API identity unchanged keep helper names unchanged,
- helper-name changes imply a major-allowed API change.
Default expression body changes are excluded from the fingerprint, so implementation-only default rewrites do not rename helpers.
Collision and shadowing policy
Assumption: Identifier.synthetic(...) names are not definable by users in Bosatsu source.
Under that assumption:
- user-vs-helper collisions cannot occur,
- user shadowing of helper names cannot occur.
For generated helpers:
- we generate at most one helper per constructor parameter slot,
- emitted name is a pure function of
DefaultNameV1key for that slot, - assuming no Blake3 collisions, two different keys cannot produce the same emitted name.
So helper-helper collisions cannot occur under the no-Blake3-collision assumption.
To avoid non-deterministic behavior, we still do not fall back to unusedNames; any observed duplicate generated helper name should be treated as an internal invariant failure.
5. Scope rule for default expressions (v1)
To keep evaluation acyclic and predictable, a default expression may reference:
- Imported names.
- Top-level values defined earlier in source order.
- Earlier generated default helpers.
It may not reference constructor parameters (no dependent defaults in v1).
Rejected example:
struct S(a: Int, b: Int = a) # rejected
This matches a DAG-friendly ordering and avoids introducing implicit call-site scope.
6. Record-constructor conversion
In SourceConverter record-constructor handling:
- Look up constructor params with default metadata.
- Build full positional arg list by field name:
- explicit arg
- default helper global
MissingArg.- Emit existing
Expr.buildApp(cons, fullArgs, ...).
Because this remains an ordinary application after elaboration, the rest of inference/normalization/codegen stays stable.
Type Errors and Regions
Why region capture needs parser/data-model changes
Current constructor args in Statement.Struct/Statement.EnumBranch are just (Bindable, Option[TypeRef]), so there is no per-default region.
To surface precise errors for defaults, we should change parsed arg shape to carry:
- field name,
- optional declared type,
- optional default expression,
- default-clause region (recommended: region covering
= <expr>), - arg region.
The parser change is to parse default clauses with .region and keep that region on the arg node.
How this plugs into the current typechecker pipeline
Current flow already has good region plumbing:
SourceConverter.toProgrambuildsExpr[Declaration].Infer.typeCheckLetsusesHasRegion[Declaration]when creatingInfer.Error.PackageError.TypeErrorInrenders source snippets from those regions viaLocationMap.
Defaults should use this same path by generating helper lets with region-aware tags.
Default helper typing strategy
For each defaulted field, generate helper RHS as a typed annotation against the field type:
__bosatsu_default_Foo_a = (<default-expr> : <field-type>)
Region policy:
- The annotation tag region is the stored default-clause region (
= <expr>). - The inner default expression keeps its own parsed subregions.
Effect with current Infer behavior:
checkAnnotatedreports expected-type region from the annotation tag.- Found-type region comes from the inner expression.
PackageError.TypeErrorIntherefore highlights the original default source location, not an invented synthetic location.
Example:
struct Foo(a: Int = "foo")
reports a normal type mismatch at the default clause (expected Int, found String).
Non-type errors for defaults
Default-scope policy violations (for example, forbidden forward/default dependency shape) should be emitted as SourceConverter errors at the stored default-clause region.
This keeps:
- parse/scope errors in
SourceConverterErrorsIn, - type mismatches in
TypeErrorIn, with both pointing to the same user-visible source location.
API Compatibility Rules
Compatibility checks live in library/ApiDiff.scala and are enforced from LibConfig.validNextVersion.
Existing constructor-shape rules remain
These remain major-only:
- Constructor add/remove.
- Constructor index change.
- Param add/remove.
- Param rename.
- Param type change.
Reason: positional constructor calls are intentionally unchanged and remain part of API surface.
New default-specific diffs
Add constructor-param default diffs:
ConstructorParamDefaultAdded(None -> Some) : allowed inminorandmajor, disallowed inpatch.ConstructorParamDefaultRemoved(Some -> None) :majoronly.
If default exists in both versions, expression/body changes are treated like ordinary value-body changes (not type-shape diffs). Adding a default stays minor-only even if no constructor type/arity changes are made, because patch requires exactly unchanged API.
Protobuf and Serialization
Schema change
Extend FnParam in proto/src/main/protobuf/bosatsu/TypedAst.proto with a proto3 int32 pointer to the helper binding name.
Example:
message FnParam {
int32 name = 1;
int32 typeOf = 2;
int32 defaultBindingName = 3; // 1-based string-table index; 0 means no default
}
Backward compatibility
- Old serialized artifacts have no field
3; proto3 default is0, interpreted asNone. - New decoder therefore treats all existing libraries/packages as constructors with no defaults.
- Unknown-field behavior keeps forward-read tolerance for older decoders.
Converter changes
In ProtoConverter:
- Encode/decode
defaultBindingNameon constructor params. - Continue serializing generated helper lets as ordinary package lets.
No change is required to match/pattern protobuf encoding.
Dependency Visibility
Default expressions may reference imported packages, but those references are contained in generated helper bindings in the defining package. Consumers of the constructor depend only on the defining package’s constructor metadata/helpers, not on direct visibility of the helper’s internal expression dependencies.
This avoids introducing named-argument-like implicit imports at constructor call sites.
Imports, Exports, and Customs
Export/import behavior
Bosatsu reminder:
export Texports only the type name.export T()exports the type name plus all constructors.
Defaults follow constructor visibility:
- Exporting
Tonly does not expose constructor defaults. - Exporting
T()includes constructor default metadata and the associated synthetic helper values needed to realize those defaults. - Importing constructor names via
T()automatically links the default behavior for record construction in downstream packages. - Default helper names are not a user-facing import surface; they are synthetic implementation details attached to constructor export/linking.
Customs (PackageCustoms.allImportsAreUsed)
No new user-visible import item is introduced for defaults.
Consequences:
- Unused-import checks remain keyed to the explicit imported constructor/type/value names.
- Using record construction that relies on defaults still counts as using the constructor import (same as any constructor use).
- There should be no new false-positive unused-import errors caused solely by default helpers.
Unused-value reachability (PackageCustoms.noUselessBinds)
Bosatsu normally reports unused top-level values unless they are reachable from roots (exports/main/tests/non-binding uses).
For defaults:
- helper lets are synthetic (
Identifier.synthetic(...)), - they are treated as synthetic exports reachable from exported constructors (
T()path), - thus they are on the export-reachability path under the normal rule,
- and because they are synthetic, they are excluded from user-facing unused-let diagnostics (
Identifier.isSynthetic) so implementation details do not surface as lint noise.
Implementation Plan
- AST/parser:
- Extend
Statement.Struct/Statement.EnumBrancharg model to carry optional default expression. - Update parser/printer for
arg [: Type] [= expr]. - Allow empty record-construction braces (
C {}) in parser; rely on existing missing-field checks in conversion. - rankn/type metadata:
- Extend
ConstructorFnarg representation with optionaldefaultBinding. - Update
TypeEnvhelpers (getConstructorParams, etc.) to expose default metadata. - Source conversion:
- Generate default helper bindings.
- Allocate helper names from
DefaultNameV1API fingerprint (no freshness fallback). - Enforce default-expression scope rule.
- Update record-constructor expansion to fill omitted defaulted fields.
- Export/linking/customs:
- Mark default helpers as synthetic constructor-linked exports when constructors are exported (
T()). - Ensure reachability roots include these synthetic constructor-linked defaults.
- Inference/typing:
- Typecheck helper bindings against declared field types.
- Preserve existing constructor-function typing.
- API diff:
- Add default-added/default-removed diff cases.
- Wire semver validity as above.
- Proto:
- Add
int32 defaultBindingNametoFnParam(0=> absent). - Update encode/decode.
Test Plan
- Parsing/printing:
- Struct and enum defaults roundtrip.
- Record-constructor syntax accepts
C {}. - Typing/elaboration:
- Record construction succeeds with subset fields when defaults exist.
C {}succeeds when all fields have defaults.- Missing non-defaulted field still errors.
- Positional constructor call arity behavior unchanged.
- Pattern matching:
- Existing pattern behavior unchanged.
- Scope restrictions:
- Default references to constructor params rejected.
- Forward-reference defaults rejected (or reported) per ordering rule.
- API diff/semver:
- Add default => minor-valid, patch-invalid.
- Remove default => major-only.
- Param add/remove still major-only even if default exists.
- Add golden test: unchanged constructor API identity => unchanged generated default-helper names.
- Protobuf compatibility:
- Decode old proto (without field) as no defaults.
- Roundtrip new proto with defaults preserved.
- Naming collisions:
- Assert generated helper names are unique for all defaulted params in a package.
- Document invariant: synthetic helper names are not user-definable in Bosatsu source.
- Visibility and reachability:
export Tdoes not permit external default-backed construction.export T()does permit external default-backed construction.- No user-visible unused-let errors are produced for synthetic default helpers.