Coercions, seals, and nested drafts - #369
Merged
Merged
Conversation
Coercions normalize input before the type check and run only at input boundaries — the initializer, writers, and draft assignment. Seals fix a value's final representation (freezing it, for example) and run on every path that stores a value into a real object, including from_props and marshal_load. Both compose with >> and <<; any arrangement that would run a coercion after a seal is rejected at composition time. A property takes either through its existing block slot: the proc built by Literal::Coercion() or Literal::Seal() carries its pipeline structure, and prop splits it into the property's coercion and seal. Collection generics compose too, via Literal::Coercions::Composable. The Immutable and DeepImmutable coercions become seals; NilIfEmpty becomes an explicit coercion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Marshal.load rebuilds contained objects unfrozen, so a property value that was frozen when dumped — a _Frozen-typed value, for example — came back mutable and failed the load-time type check. marshal_dump now records which values were frozen (payload version 2) and marshal_load re-freezes them before checking. Version 1 payloads still load; old readers ignore the extra element. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A draft slot typed as a Literal::Properties class now also accepts a draft of that type (or of a subtype), and finalize builds nested drafts depth-first — unless the drafted type's property wanted a draft as-is. The draft itself is never mutated: finalizing twice builds two independent values. The union member is Literal::Draft::Type, a lightweight matcher with Literal::Draft(T)'s semantics but no generated class, so recursive types don't recurse forever at draft-class definition. __thaw__ becomes __relax__: one walk relaxing both representation (_Frozen) and finality. Draft coercions skip nested drafts the way they skip Undefined — a draft isn't the drafted type's input yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e729cf03b7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
_Deferred property types — how recursive and forward-referenced models are declared — passed through __relax__ unchanged, so their draft slots were never widened to accept nested drafts. The relaxation now wraps the deferral in another _Deferred that relaxes on materialization, keeping constants that don't exist yet at draft-definition time unresolved. The draft union also puts Undefined first: union members are tried in order, so matching the unset sentinel by identity keeps deferred members from materializing during prop's default check and unset-slot type checks — previously drafting a type with an unresolvable deferred property raised NameError at definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A plain block coercion runs via instance_exec, so self is the object being constructed — but composed stages were invoked with Proc#call, leaving them bound to their definition site. A coercion that worked alone would raise NameError or read the wrong state once composed. __compose__ now instance_execs each stage against the current self. Also adds the first direct tests for the composition algebra: both compose directions, coercion-into-seal splitting, the coercion-after- seal error paths, and seals being uncallable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Literal::Draft(Foo) now returns the same generated class on every call until Foo's properties change, instead of generating a fresh class per call. The cache key is the class's schema, and each entry records the schema's sorted-properties snapshot — an array the schema replaces on every mutation, so its identity changes exactly when the schema does. Reopening a class invalidates lazily with no mutation tracking, and the weak keys let anonymous classes and stale drafts be collected. The schema is the key rather than the snapshot because WeakKeyMap compares keys with eql?: a subclass's snapshot is eql? to its parent's, but each class has its own schema, compared by identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
joeldrapper
force-pushed
the
coercions-and-seals
branch
2 times, most recently
from
July 31, 2026 14:14
7f56d84 to
fa62e01
Compare
Draft#finalize now yields the draft to an optional block — after any
props passed to finalize are assigned, before the value is built — for
last touches like conditional assignment. Draft classes gain build,
which constructs, yields, and finalizes in one call: positional and
keyword arguments go to the draft's constructor, the block to finalize.
Literal::DataStructure gets the same build, so Data and Struct can be
conditionally constructed without Literal::Draft at the call site —
structs are mutable, but required properties still make incremental
construction impossible without a draft:
Foo.build(id: 1) do |draft|
draft.name = name if bar?
end
Deliberately not on Literal::Properties itself — build is a common
user-defined factory name, and classes extending Properties directly
can use Literal::Draft(self).build.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
joeldrapper
force-pushed
the
coercions-and-seals
branch
from
July 31, 2026 14:16
fa62e01 to
bb22a5a
Compare
Relaxation was a private walk inside draft-class generation — a shadow type transformation invisible to the rest of the algebra. It's now a first-class type: _DraftState(T) matches what a slot typed T may hold while drafting, with representation (_Frozen) and finality relaxed so a Literal::Properties class also admits a draft of itself. Same semantics, relocated: draft properties become _Union(Undefined, _DraftState(T)), errors and introspection name what a draft slot actually accepts, and the question every new combinator must answer — what does relaxation mean here? — now has one visible home. The unset sentinel stays outside in the union, where its exact containment marks the property omittable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seals belong to construction: they establish a value's final representation as it enters an object. A marshal-loaded object was constructed — and sealed — before it was dumped, and marshal_dump already records each value's frozen state for marshal_load to restore. Re-running seals on load conflated restoring with constructing, and a seal that dups would even produce different objects than were dumped. from_props keeps sealing: it takes values that may never have been through construction, and computes defaults itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
What this does
Three related changes, one commit each:
Literal::CoercionandLiteral::SealSplits the property pipeline into two explicit stages:
from_propsormarshal_load.from_props, which takes values that may never have been through construction and computes defaults itself. They don't run onmarshal_load: a loaded object was constructed (and sealed) before it was dumped, and the frozen state recorded at dump is what gets restored. Seals must be idempotent and type-preserving.Both compose with
>>and<<. Composing two coercions yields a coercion; composing a coercion into a seal yields a seal whose coercion runs first. Any arrangement that would run a coercion after a seal is rejected at composition time — andSealis deliberately not callable, soraw_proc >> sealfails immediately instead of silently demoting the seal.Properties take either through the existing block slot — the proc built by
Literal::Coercion()/Literal::Seal()carries its pipeline structure, andpropsplits it into the property's coercion and seal. Collection generics compose too:Literal::Array(String) >> Immutable.The built-in
ImmutableandDeepImmutablebecome seals;NilIfEmptybecomes an explicit coercion.Frozen state through Marshal
Marshal.loadrebuilds contained objects unfrozen, so a property value that was frozen when dumped (a_Frozen-typed value, for example) came back mutable and failed the load-time type check.marshal_dumpnow records which values were frozen (payload version 2) andmarshal_loadre-freezes them before checking. Version 1 payloads still load, and old readers ignore the new payload element.Nested drafts
A draft slot typed as a
Literal::Propertiesclass now also accepts a draft of that type (or a subtype), andfinalizebuilds nested drafts depth-first — unless the drafted type's property wanted a draft as-is, in which case it stays one. Finalize never mutates the draft: finalizing twice builds two independent values.Under the hood, relaxation is a first-class type:
_DraftState(T)matches what a slot typedTmay hold while drafting — one walk that relaxes both representation (stripping_Frozen) and finality (wideningTto accept drafts ofT). Draft properties are_Union(Undefined, _DraftState(T)), so errors and introspection name what a draft slot actually accepts. The union member isLiteral::Draft::Type, a lightweight matcher withLiteral::Draft(T)'s===semantics but no generated class, so recursive types (aPersonwith aPersonproperty) don't recurse forever at draft-class definition. Draft coercions skip nested drafts the way they skipUndefined— a draft isn't the drafted type's input yet; it meets the coercion's output contract at finalize through its own construction.Deliberately out of scope: drafts inside collections (
_Array(T),Literal::Array(T)). Widening element types would force a type-driven relax/restore pair over the whole type algebra; holding that until there's a real need.Testing
Each commit is individually green against the full suite (3172 → 3172 → 3176 → 3192 tests). New tests cover marshalling frozen-state round-trips, version 1 payload compatibility, and nested drafts: slot acceptance (subtypes, through
_Nilable), depth-first finalize with nested defaults, inner missing-property errors, purity, draft-typed slots keeping their drafts, and coercion passthrough. Known gap: the>>/<<composition error paths have no direct tests yet.🤖 Generated with Claude Code
Canonical draft classes
Literal::Draft(Foo)now returns the same generated class on every call untilFoo's properties change, instead of a fresh class per call. The cache is keyed by the class's schema in anObjectSpace::WeakKeyMap, with each entry recording the schema's sorted-properties snapshot — an array the schema replaces on every mutation, so its identity changes exactly when the schema does. Reopening a class invalidates lazily with no mutation tracking, and anonymous classes (and their stale drafts) stay collectable.Building through drafts
Draft#finalizetakes an optional block, yielded the draft after any props are assigned and before the value is built — for last touches like conditional assignment. Draft classes gainbuild— construct, yield, finalize in one call, with arguments going to the constructor and the block tofinalize— andLiteral::DataStructure.buildlayers the same onto Data and Struct themselves (structs are mutable, but required properties still make incremental construction impossible without a draft):Review follow-ups from the Codex findings are also in: deferred property types relax lazily (and the draft union now puts
Undefinedfirst so unresolved forward references never materialize early), and composed coercion stages run in the property context viainstance_exec, with the composition algebra now under direct test.