Skip to content

Optional + Default Record Fields - #10320

Open
jaredramirez wants to merge 16 commits into
mainfrom
jared/optional-records
Open

Optional + Default Record Fields#10320
jaredramirez wants to merge 16 commits into
mainfrom
jared/optional-records

Conversation

@jaredramirez

@jaredramirez jaredramirez commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

This PR implements optional + defaulted record fields.

Types

  • Each RecordField gets a Presence variable, thats flex, present, optional, & default: DefaultId. When uniyfing, flex < required < default, where the rightmost wins. optional is incompatible with required/defaulted.
    • Note that defaulted must win/merge over required
    • Each defaulted expr must be literal
    • Unifying two defaulted fields of the same type with different default values produces an error
  • When unifying two records, missing optionals/defaults get pushed into record extensions. Then, we add new unification logic to allow {} to unify with a record that contains only optionals/defaults

Lowering:

  • Optional fields have the are mapped to a structrual tag union representation [#Missing, #Present(a)]
  • Defaulted fields keep the plain required-field repr (inline slot) since the field is always present at runtime. The default's identity is part of the field's concrete (monotype) identity, so two records that differ only in a default are distinct concrete types — that's what keeps derived codecs sound (a required record shaped like a defaulted one must not share its parser)

JSON derived codec:

  • When encoding an optional field, if the field is not present in the roc record (ie is #Missing) then the field is omitted in the encoded JSON. Conversely when decoding, if the field is not present in the JSON, then it is set as #Missing in the roc record. Note that if the JSON field is presen but null, decoding fails! null != optional but not present
  • When decoding a default field, if the field is not present in the JSON, then the default value is used to construct. As with optional fields, an explicit null fails decoding. Encoding a defaulted field always emits it (including when it equals the default)

Open Questions:

Given the following:

mk_job : { num_threads : U8 } -> Job

config_a : { num_threads : U8 }
config_a = { num_threads : 2 }

config_b : { num_threads : U8, name ?: Str }
config_b = { num_threads : 2 }


main = || {
  job_a = mk_job(config_a)
  job_b = mk_job(config_b)
}

Results in mk_job having two concrete versions after monomorphization. When we instantiate mk_job with config_b, the record that mk_job accepts must be widened to accept the record+optional fields. This works just fine, but may be unintuiative to the user who is trying to reason about what is getting monomorphized in this code & could result in many versions of the same function.

One way to solve this is to introduce polarity-based restriction, to disallow record widening in the input position. Ths would cause job_b = mk_job(config_b) to be an error. But we would loose some expressiveness. IMO we should do this polarity-based restriction, for the same reason we restrict let-generalizationin Ayaz's Let's Not write up.


Implements optional record fields (name ?: Type, accessed with .?) and defaulted record fields (name : Type ?? default, construction-optional) end-to-end: parse → canonicalize → check → checked module output → monotype/LIR lowering → interpreter, dev, wasm, and llvm backends — including derived JSON codecs. The authoritative design lives in design.md ("Field Kinds (All-Dynamic Optional Fields)" and "Defaulted Fields"). The branch is a commit stack on current main: the squashed feature commit, the ?: syntax commit, follow-ups for kind defaulting, literals-only defaults, optional update/destructure, cleanup, the nominal field-kind resolution fix, the reserved slot labels, width-absorption eval pins, and JSON codec derivation.

Syntax

syntax meaning
optional field { name ?: Str } may be missing at runtime (tagged slot); read with .?nameTry(Str, [MissingField])
optional access record.?name works only on optional fields; .? on a required/defaulted field is rejected
defaulted field { count : U8 ?? 10 } construction may omit it (compiler fills the default); at runtime an ordinary required field, read with .count

Access chains collapse to one flat Try per chain: o.?b.?c : Try(C, [MissingField]) (never nested), a required segment after an optional one rides the Ok path, the first missing slot short-circuits to the shared MissingField, and one ?? fallback covers the whole chain. The former :? spelling recovers with a did-you-mean diagnostic and the formatter migrates it to ?:.

Checking

  • Annotations pin kinds concretely in every position (no polarity split); kind unification is the join lattice from the summary above; width absorption into closed rows is opt-in (only a kind resolved optional/defaulted from an annotation absorbs — undetermined kinds never do, so typo'd extra fields stay errors).
  • List literals seed their element expectation from the annotation, so xs : List({ a ?: U8 }); xs = [{ a: 1 }, {}] typechecks — as does the defaulted equivalent.
  • Default values must be literals (numbers, interpolation-free strings, tags, and lists/records/tuples built only from literals — judged at canonicalization). Every default-cycle shape is impossible by construction; the previous reference-based rules are deleted as subsumed. Defaults must be concrete; two separately-written defaults never merge.
  • .? on a required/defaulted field is judged at every generalization boundary, so let-polymorphism can't launder a receiver past the check.
  • Kind defaulting is a real solver pass: a still-flex field kind commits to required at module finalize; scheme interiors deliberately stay flex so instantiations can still join ?: annotations.
  • Record update sets optional fields with creation semantics; record destructure binds optional fields as Try via a deferred kind-directed judgment (nested { x: Ok(y) } patterns match the Try value, including in match branches with guards).
  • Nominal-backed records resolve field kinds through their declaration's backing row across module views (tri-state resolution: found / scheme-interior→required-equivalent / absent→invariant — never a guess), with argument substitution for generic nominals' slot types.

Checked module output + lowering

  • CheckedRecordField carries a field kind (required / optional / defaulted + default identity); .? access segments publish their mode; archived default expressions ship in CheckedBodyStore.default_exprs and are restored at every construction site that omits the field, then constant-folded via compile-time roots.
  • Optional slots lower as the closed structural union [#Missing, #Present(τ)] whose labels are compiler-reserved (# starts a comment in source, so no user tag can spell them — same namespace as #interp_0 idents). Sorted variant order matches the unprefixed names, so the ABI discriminant contract (variant 0 = missing) is unchanged, and slot recognition below checking (inspect, codecs) is an exact lossless read-back rather than a shape test. A user-annotated [Missing, Present(τ)] is an ordinary distinct tag union.
  • Glue computes the identical slot layout from the published kind (by variant index — labels never surface), so ?: stays legal across the Host Symbol ABI.
  • Inspect renders a present slot as the plain field value and a missing one as <missing>.

Derived JSON codecs

?: fields participate in derived Json encode/parse as the slot-kind sibling of the existing Try(τ, [Missing]) convention (which is unchanged):

  • Encode: a #Present slot emits its field with the payload's encoder; a #Missing slot omits the field entirely.
  • Parse: an absent JSON field materializes #Missing for a ?: field and fills the archived default for a ?? field; a present one parses at the payload/inline type. Explicit null stays an error for both. Records with required fields still demand MissingRequiredField(Str) in the format error row exactly as before; a record whose fields can all self-fill validates with a closed error row.
  • Pinned by test/cli/JsonOptionalFieldKinds.roc (absent/present parse, .? read-back, encode omit/emit, nested round-trip) on interpreter + dev backends.

Semantics under discussion: width-absorption polarity

Today a closed annotated parameter row absorbs a caller's extra optional fields by widening its instantiation: f : { b : Str } -> Str accepts a { a ?: U64, b : Str } value (unification merges — no subtyping — and bodies monomorphize per solved instantiation, so layouts always agree; pinned across all four backends by three eval tests marked semantics-under-debate). The open language question is whether absorption should be polarity-restricted to construction sites only (absorb only into a literal's still-unbound row), which would make every specialization of f an exact instantiation of its annotation and reject wide-value-into-narrow-param calls. The type store already distinguishes the two row provenances (record_unbound vs committed record), so the restriction is a guarded condition on the absorption path plus a diagnostic — a small change once the semantics are decided. Until then this PR ships the permissive merge semantics.

Review rounds

Two full review passes are folded in (18 self-review comments + two adversarial-review rounds). Fixes landed on-branch: nominal-backed construction/update panic (kind lookup now walks nominal backing; required-kind fallback removed), dead snapshot-tool helper deleted, default_exprs deinit, dev-backend sub-word immediate stores (sizes 1–7), and kind-directed composition with upstream's new RecordUpdate monotype node. Filed upstream: #10416, #10483 (top-level destructure publication gap; near-term REPL guard included here), #10576 (generic update supplying an optional field — checker-admitted, loud postcheck invariant; needs a checker-side decision). Two earlier filings are closed: #10415 (obsoleted by the literals-only default restriction) and #10466 (the x64mac ZigGlue HFA mismatch root-caused upstream as a Zig compiler ABI bug, tracked by #10392).

Deferred (documented in design.md)

Testing

Full unit suite green (4135); snapshot corpus regenerated with no drift; multi-backend eval suite green including 60+ optional/defaulted runtime tests (heap payloads + ARC, nested chains, generic passage, Try interop, record update slot copying, structural equality, Inspect rendering, mixed-presence lists, width absorption on all four backends); JSON codec CLI tests; wasm static-lib suite; cross-module and serialization round-trip tests pin the published kinds; canonical-key agreement verified byte-for-byte between solver and checked representations.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV

@jaredramirez jaredramirez self-assigned this Jul 22, 2026
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch 6 times, most recently from ebd36d5 to 9bed4bf Compare July 24, 2026 20:53
@jaredramirez jaredramirez changed the title Optional Records Optional + Default Record Fields Jul 25, 2026
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch from 7a7fceb to 084e3bc Compare July 25, 2026 15:56
Comment thread src/check/snapshot/diff.zig Outdated
Comment thread src/check/canonical_type_keys.zig Outdated
Comment thread src/check/canonical_type_keys.zig Outdated
Comment thread src/check/Check.zig Outdated
Comment thread src/check/Check.zig Outdated
Comment thread src/check/checked_artifact.zig
Comment thread src/check/checked_artifact.zig Outdated
Comment thread src/check/checked_artifact.zig
Comment thread src/check/dispatch_evidence.zig Outdated
Comment thread src/collections/safe_list.zig
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch 4 times, most recently from a9a29a6 to 0ef3d8c Compare July 27, 2026 19:32
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch 2 times, most recently from 9788fce to 9a413de Compare July 28, 2026 22:41
@jaredramirez
jaredramirez marked this pull request as ready for review July 29, 2026 03:04
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch from 10b1333 to e4efb7e Compare July 29, 2026 14:51
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch 2 times, most recently from b8b9723 to 84dd70a Compare July 30, 2026 01:23
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch 3 times, most recently from 1012a08 to 443dee5 Compare July 30, 2026 16:57
auto-merge was automatically disabled August 3, 2026 16:06

Pull request was converted to draft

@jaredramirez
jaredramirez force-pushed the jared/optional-records branch from 15f762e to 45226fe Compare August 3, 2026 16:47
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch 8 times, most recently from d80ec26 to 85bbd6c Compare August 6, 2026 22:21
jaredramirez and others added 15 commits August 6, 2026 18:28
Implements optional (name :? Type, .? access -> one flat Try(T, [MissingField])
per chain) and defaulted (name : Type ?? default) record fields end-to-end:
parse -> canonicalize -> check -> checked module output -> monotype/LIR
lowering -> interpreter, dev, wasm, and llvm backends.

- Field kinds are solved by ordinary unification on a join lattice
  (flex < required < defaulted, optional incomparable); annotations pin
  kinds concretely in every position; width absorption into closed rows is
  opt-in (resolved optional/defaulted only, flex never absorbs).
- A default is identified by (declaring module content identity, expr node);
  defaults must be pure, concrete, and closed over module scope; they are
  checked once at finalize, archived in the checked module output, and
  materialized at every construction site that omits the field, including
  bare {} literals; effectful defaults poison to err and are never
  const-folded.
- Optional slots lower as closed structural [Missing, Present(t)] tag
  unions; .? chains compile to runtime tag tests with the first missing
  slot short-circuiting to the chain's shared MissingField; Inspect renders
  Present payloads as plain values and Missing as <missing>.
- Direct default self-reference is a canonicalization diagnostic (consuming
  Can's own scope resolution); indirect cycles surface as ordinary circular
  value definitions via default demand edges in the dependency graph.
- List literals seed their element expectation from the annotation (same
  rigids-flexed machinery as if/match branches), so mixed-presence elements
  typecheck under a :? / ?? annotation.
- Includes the R1-R18 review round, adversarial-review fixes, and CI gate
  cleanup (tidy, lints, semantic audit vocabulary, test wiring, size pins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
Flips the annotation marker back from `:?` to `?:`. Access (`.?`) and
defaults (`??`) are unchanged. The legacy-recovery machinery inverts:
`:?` now parses with a did-you-mean diagnostic and the formatter
migrates it to `?:`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
…efaulting pass)

A field kind var minted by a literal and never constrained now commits to
`required` in the solved graph at module finalize (same timing as the
literal-defaulting rounds), instead of each read boundary applying a
flex-means-required convention. Scheme interiors deliberately keep flex
kinds: a generalized row's kind may still join an `?:` annotation at an
instantiation site, and that acceptance is pinned by test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
A `?? default` expression must now be a closed literal: a numeric, string,
or zero-argument tag literal, or a tag application / list / record / tuple
whose components are themselves literals (unary minus folds into numeral
parsing). No operators, calls, or name references of any kind.

Because every default-cycle edge required a default to reference a def,
this closes the alias-mediated cycle gap by construction and subsumes
three rules into one canonicalization diagnostic: the local-binding
capture check, the direct self-reference judgment, and the default
demand edges in the dependency graph (all removed). Module-constant
defaults (`?? ten`) are no longer legal; robust reference support can
return later with real decl-aware cycle edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
`{ ..r, x: v }` on an optional field now typechecks v against the payload
type and writes `Present(v)` into the slot, mirroring construction. The
per-field update probe is kind-flexible instead of demanding `required`:
base optional accepts, base required/defaulted overwrites inline as
before, and a still-flex base kind stays flex (committing falls to the
finalize kind-defaulting pass, exactly as for literal fields).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
…-directed inference

`{ x } = r` (and match record patterns) on an optional field now binds
x : Try(T, [MissingField]) instead of rejecting. The binder starts as a
fresh var with a recorded pending destructure; at the same generalization
boundary where .? accesses are judged (finalize backstop), the settled
kind directs the bind: required/defaulted unify T, optional unifies the
nominal Try, still-flex defaults required (so plain records are
unchanged). Lowering reads the slot and materializes Ok/Missing exactly
like a one-segment .? chain, so nested sub-patterns match the Try value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
…ypeVar entry

Store.fromTypeVar had zero callers, so Zig never analyzed the resolver:
it still referenced the pre-kinds RecordField API and carried stale
'optional record field layout is not implemented' panics from before the
tagged-slot lowering landed. The live path is the glue/cache resolver
(checked_artifact_layout_resolver.zig), which consumes published field
kinds. Also renders optional/defaulted fields in docs extraction, which
was the last consumer still panicking on them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
The interactive REPL accepted destructure definitions into its synthetic
module, but top-level non-assign def patterns are never published
(#10483), so referencing a binder panicked postcheck. Mirror
the snapshot REPL's rule: definitions must bind a top-level identifier,
rejected with an actionable diagnostic before session state changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
The `:?` spelling was never in a released build, so calling it old was
misleading; the report title and prose now describe it as invalid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
Adds corpus snapshots demonstrating the feature surface: setting optional
fields through record update, destructuring them as Try (incl. nested
patterns and match branches), the literals-only default rule from both
sides, and a runtime tour covering construction, .? chains, and Inspect's
plain-value / <missing> rendering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VnRxmioksj59fxuAkqpPvV
…acking; drop the required-kind fallback

checkedRecordFieldByName now walks nominal backing across views exactly like
recordDestructFieldKind, and construction/update sites treat a missing kind
as an invariant violation instead of guessing .required. Also deletes the
dead snapshot-tool computeTransformedExprType (referenced a nonexistent
Presence variant) and documents the inspect-path shape back-decode in
design.md instead of claiming it was already pinned.
The slot union's labels are now compiler-internal names no user tag can
spell (# starts a comment in source; precedent: #interp_0 idents), so
optionalFieldSlot's shape test is exact and the user-annotated
[Missing, Present(t)] collapse documented in design.md disappears —
that union is now an ordinary, distinct tag union everywhere.
Sorted variant order is unchanged (#Missing < #Present), so slot
layout, discriminants, and the Try-backing byte equivalence hold.
…ated parameter

A closed annotation's instantiated row absorbs a caller's extra optional
fields by widening (unification merges; no subtyping), and the body
specializes per solved instantiation — so the layouts agree at every
call. Pinned with the extra optional field sorted BEFORE the accessed
field, present and missing, plus narrow and wide calls through the same
function in one program.
Encode: a #Present slot emits its field with the payload's encoder; a
#Missing slot omits the field entirely. Parse/Decode: an absent JSON
field materializes #Missing, a present one parses at the payload type
and wraps in #Present — keyed on the explicit checked field kind, the
same axis construction and update consume. This is the slot-kind
sibling of the existing Try(t, [Missing]) codec convention, which is
unchanged.
…fault identity

Adversarial review refuted the first cut (a Builder-global defaults table
keyed by row content hash): a required row shaped like a ?? row shared the
key, so its derived parser silently self-filled instead of erroring
MissingRequiredField, same-shape rows with different defaults collided
last-writer-wins, and registration's digest walk could panic on recursive
defaulted nominals. Root cause: specialization is monotype-keyed but
derived-parse behavior was checked-type-dependent.

Fix direction A: the Monotype record field itself carries the ?? default
identity (Type.FieldDefault: declaring module identity + default expr
node), so rows disagreeing about defaults are DISTINCT monotypes and
'same monotype => same behavior' is an invariant again. The identity is
folded into both Monotype digest writers and both structural-equality
walks, and rides every downstream field carrier: InstField (graph import,
merge — asserted equal on unification — and sealing), lambda-solved and
lambda-mono fields and digests, and ConstStore type evidence
(TypeFieldDefault, translated across name stores like labels;
serialized_layout_version 57->58, golden hash re-measured). The defaults
table, row-content hashing, and GeneratedParserDefAddress.defaults_digest
are deleted; the parse ladder reads the default off the field
(parserFieldDefaultFor). Reusing an already-materialized const under
sealed emission compares the finished monotypes instead of growing the
frozen graph. Twin behavior is pinned in
test/cli/JsonOptionalFieldKinds.roc (required twin still errors; ?? 10 vs
?? 20 each fill their own default).

Feature behavior (user-decided): an absent key fills the archived
default; a present key parses at the inline type; explicit null stays an
error (null is a value, absence is not); encode always emits. Checker
gate recordParseNeedsRequiredFieldError skips ?:/??-kind fields; archived
defaults are field_default compile-time roots evaluated in a leading
batch.

Also folded from the review round:
- lowering mirrors of the self-fill analysis
  (parserShapeNeedsRequiredFieldError + graph twins, invalid-value arm)
  learn ?: slots (graphOptionalFieldSlotPayload reserved-label read-back)
  and ?? defaults; pinned with tag-union-payload expects
- dead pol: Polarity threading deleted (types.Polarity removed)
- judgeFieldKindsAtBoundary extracts the 6-site judgment choreography
- field-default-first finalization is an explicit dependency edge
  (RootCompletionState.pending_field_defaults) instead of positional
  batch tracking
- recordDestructFieldKind now wraps the shared checkedRecordFieldByName
  cross-view walk
- design.md: Type.FieldDefault + parse-fill machinery documented,
  cross-module materialization paragraph un-staled, width-absorption
  debate noted
@jaredramirez
jaredramirez force-pushed the jared/optional-records branch from 85bbd6c to a8dcca6 Compare August 6, 2026 22:28
@jaredramirez
jaredramirez marked this pull request as ready for review August 6, 2026 22:44
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (217 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Defaulted record fields: open numerals in ?? defaults commit before dispatch constraints pin them

1 participant