Optional + Default Record Fields - #10320
Open
jaredramirez wants to merge 16 commits into
Open
Conversation
jaredramirez
force-pushed
the
jared/optional-records
branch
6 times, most recently
from
July 24, 2026 20:53
ebd36d5 to
9bed4bf
Compare
jaredramirez
force-pushed
the
jared/optional-records
branch
from
July 25, 2026 15:56
7a7fceb to
084e3bc
Compare
jaredramirez
commented
Jul 26, 2026
jaredramirez
force-pushed
the
jared/optional-records
branch
4 times, most recently
from
July 27, 2026 19:32
a9a29a6 to
0ef3d8c
Compare
jaredramirez
force-pushed
the
jared/optional-records
branch
2 times, most recently
from
July 28, 2026 22:41
9788fce to
9a413de
Compare
jaredramirez
marked this pull request as ready for review
July 29, 2026 03:04
jaredramirez
force-pushed
the
jared/optional-records
branch
from
July 29, 2026 14:51
10b1333 to
e4efb7e
Compare
jaredramirez
force-pushed
the
jared/optional-records
branch
2 times, most recently
from
July 30, 2026 01:23
b8b9723 to
84dd70a
Compare
jaredramirez
force-pushed
the
jared/optional-records
branch
3 times, most recently
from
July 30, 2026 16:57
1012a08 to
443dee5
Compare
auto-merge was automatically disabled
August 3, 2026 16:06
Pull request was converted to draft
jaredramirez
force-pushed
the
jared/optional-records
branch
from
August 3, 2026 16:47
15f762e to
45226fe
Compare
jaredramirez
force-pushed
the
jared/optional-records
branch
8 times, most recently
from
August 6, 2026 22:21
d80ec26 to
85bbd6c
Compare
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
force-pushed
the
jared/optional-records
branch
from
August 6, 2026 22:28
85bbd6c to
a8dcca6
Compare
jaredramirez
marked this pull request as ready for review
August 6, 2026 22:44
Contributor
|
Too many files changed for review (217 files, 100 file limit). Bypass the limit by tagging |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR implements optional + defaulted record fields.
Types
RecordFieldgets aPresencevariable, thatsflex,present,optional, &default: DefaultId. When uniyfing,flex < required < default, where the rightmost wins.optionalis incompatible withrequired/defaulted.defaultedmust win/merge overrequireddefaultedexpr must be literaldefaultedfields of the same type with different default values produces an error{}to unify with a record that contains only optionals/defaultsLowering:
[#Missing, #Present(a)]JSON derived codec:
#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#Missingin the roc record. Note that if the JSON field is presen butnull, decoding fails!null!=optional but not presentnullfails decoding. Encoding a defaulted field always emits it (including when it equals the default)Open Questions:
Given the following:
Results in
mk_jobhaving two concrete versions after monomorphization. When we instantiatemk_jobwithconfig_b, the record thatmk_jobaccepts 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 indesign.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
{ name ?: Str }.?name→Try(Str, [MissingField])record.?name.?on a required/defaulted field is rejected{ count : U8 ?? 10 }.countAccess chains collapse to one flat
Tryper 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 sharedMissingField, and one?? fallbackcovers the whole chain. The former:?spelling recovers with a did-you-mean diagnostic and the formatter migrates it to?:.Checking
xs : List({ a ?: U8 }); xs = [{ a: 1 }, {}]typechecks — as does the defaulted equivalent..?on a required/defaulted field is judged at every generalization boundary, so let-polymorphism can't launder a receiver past the check.requiredat module finalize; scheme interiors deliberately stay flex so instantiations can still join?:annotations.Tryvia a deferred kind-directed judgment (nested{ x: Ok(y) }patterns match the Try value, including inmatchbranches with guards).Checked module output + lowering
CheckedRecordFieldcarries a field kind (required / optional / defaulted + default identity);.?access segments publish their mode; archived default expressions ship inCheckedBodyStore.default_exprsand are restored at every construction site that omits the field, then constant-folded via compile-time roots.[#Missing, #Present(τ)]whose labels are compiler-reserved (#starts a comment in source, so no user tag can spell them — same namespace as#interp_0idents). 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.?:stays legal across the Host Symbol ABI.Inspectrenders a present slot as the plain field value and a missing one as<missing>.Derived JSON codecs
?:fields participate in derivedJsonencode/parse as the slot-kind sibling of the existingTry(τ, [Missing])convention (which is unchanged):#Presentslot emits its field with the payload's encoder; a#Missingslot omits the field entirely.#Missingfor a?:field and fills the archived default for a??field; a present one parses at the payload/inline type. Explicitnullstays an error for both. Records with required fields still demandMissingRequiredField(Str)in the format error row exactly as before; a record whose fields can all self-fill validates with a closed error row.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 } -> Straccepts 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 offan exact instantiation of its annotation and reject wide-value-into-narrow-param calls. The type store already distinguishes the two row provenances (record_unboundvs committedrecord), 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_exprsdeinit, dev-backend sub-word immediate stores (sizes 1–7), and kind-directed composition with upstream's newRecordUpdatemonotype 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)
{ ..r, x: _ }) — designed, not implemented.{ field ?? fallback }-style destructure sugar.Trybinder (typing still validates them).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