Skip to content

fix: correct equality, union dispatch, schema identity and the freeze - #26

Merged
btravers merged 2 commits into
mainfrom
fix/equality-union-freeze
Aug 7, 2026
Merged

fix: correct equality, union dispatch, schema identity and the freeze#26
btravers merged 2 commits into
mainfrom
fix/equality-union-freeze

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Five bugs found reviewing the source. Each was reproduced before being fixed and is now pinned by a test. None needed an exotic declaration to hit — OnlyNominal admits any branded schema, freeze.ts explicitly contemplates Map/Set fields, and shape.ts blesses z.enum as nominal.

equals used JSON.stringify

Three failures, measured on the pinned zod 4.4.3:

bigint field   a.equals(b) → THREW: Do not know how to serialize a BigInt
Set field      Set(["x"]) vs Set(["totally","different"]) → equals = true
nested record  {a:1,b:2} vs {b:2,a:1} → equals = false

The bigint case is the sharpest: equals returns a bare boolean with no Result channel, so the TypeError escaped uncaught — a direct breach of the errors-as-values rule. The Set case is a false positive on identity, the worse direction. The third meant a repository reading a row back with different JSON key order reported the entity as changed.

New equal.ts compares structurally: Set/Map by contents, Date by timestamp, typed arrays and ArrayBuffer bytewise, RegExp by source and flags, nested objects key-by-key. Arrays stay order-sensitive. Its traversal mirrors freeze.ts's, so the two agree on which shapes exist.

Union discriminant assumed a single-valued literal

union.ts read .value, which only z.literal("a") has. An enum member registered under undefined:

U.input.safeParse({kind:"b",…})  → true
U.make({kind:"b",…})             → err: Invalid discriminant "b"; expected one of "a", 
U.make({id})  (no discriminant)  → routed to B, reported B's own field error
z.literal(["c","d"]) member      → THREW at union construction

Four distinct problems: the two halves of the same public API disagreed; a payload missing the discriminant was captured by whichever member held the undefined key, defeating the dispatch design the docstring describes; the message rendered that key as empty (note the trailing "a", ); and a multi-value literal threw inside a builder.

Now reads the plural accessors (.values for literals, .options for enums) and registers a member under every value it claims. A member whose discriminant yields no values registers under no key, so a malformed declaration reports Invalid discriminant rather than silently capturing traffic.

Three of the four schema members were one object

omitBy returned its argument for an empty key list, and output was input with no computed fields. For an entity with neither generated nor computed:

input === output === createInput  → true

The design rule is "contracts compose the four plain ZodObjects", so consumers key registries by schema identity — and registering the three under distinct ids kept only the last write, with z.toJSONSchema emitting one $def all three $ref'd. contract.spec.ts missed it because its fixture declares both options.

The freeze broke z.custom callers

freeze.ts promises a z.custom/z.instanceof value is left alone, citing the exact risk that "the value may still be referenced by the caller who passed it in". It wasn't: dispatch was on runtime shape, and z.custom hands the caller's reference straight back, so a plain-object one was deep-frozen and the caller's next write threw.

The runtime genuinely cannot tell a passed-through object from decoded data — only the schema can. entity.ts now computes the skip set from the field schemas (z.custom, z.instanceof and a branded z.custom all report custom; brand is type-level in zod v4 and adds no wrapper).

Verification

Full gate green: format --check, lint, typecheck (three passes), test (138 passed, up from 122), knip, build.

New tests: equal.spec.ts covers bigint / Set / Map / nested-record / array-order / Date; union.spec.ts gains enum routing, input-and-make agreement, the missing-discriminant message, and multi-value literals; contract.spec.ts gains schema distinctness and registry independence; freeze.spec.ts gains the z.custom passthrough and confirms ordinary object fields are still frozen.

Note on sequencing

Branched from main, so it does not include #25. Both touch entity.ts — in different regions (equals/omitBy here, construct/extend/options there) — so whichever merges second needs a small rebase.

🤖 Generated with Claude Code

Five bugs found reviewing the source, each reproduced before being fixed and
each pinned by a test. All of them are in field types the package already
accepts -- `OnlyNominal` admits any branded schema, `freeze.ts` contemplates
Map/Set fields, and `shape.ts` blesses `z.enum` -- so none needed an exotic
declaration to hit.

`equals` compared `JSON.stringify` output. A bigint field made it **throw** `Do
not know how to serialize a BigInt`, escaping uncaught because `equals` returns
a bare boolean with no Result channel -- a direct breach of the errors-as-values
rule. A Set, Map or typed-array field serialised to `{}`, so entities with
entirely different contents compared **equal**, a false positive on identity.
And only top-level key order was normalised, so a nested record `{a,b}` versus
`{b,a}` compared unequal. The new `equal.ts` compares structurally.

The union discriminant lookup read `.value`, which only a single-valued
`z.literal` has. An enum member registered under `undefined`: `input` accepted
payloads `make` then rejected, a payload missing the discriminant was misrouted
to that member rather than reported, and the "expected one of" message rendered
it empty. A multi-value literal threw at union construction. It now reads the
plural accessors and registers a member under every value it claims.

`omitBy` returned its argument for an empty key list and `output` was `input`
when nothing was computed, so a plain entity had one object under three names.
Since contracts compose these four objects, a registry keyed by identity kept
only the last write.

The freeze dispatched on runtime shape, so a plain-object `z.custom` value --
the caller's own reference -- was frozen in place and the caller's next write
threw. Only the schema knows what was passed through, so `entity.ts` now
decides from it. That is what the docs already promised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 7, 2026 13:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes several correctness issues in the @btravstack/entity core runtime (equality, union dispatch, schema identity, and freezing behavior) and adds regression tests + documentation/changeset updates to pin the intended semantics.

Changes:

  • Replace equals()’s JSON.stringify comparison with structural deep equality (deepEqual) and add coverage for bigint/Set/Map/Date/records.
  • Fix union member dispatch to support enum and multi-value literal discriminants consistently across input and make.
  • Ensure the four contract schema members are always distinct objects; adjust freezing to skip schema-declared passthrough (z.custom/z.instanceof) fields.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/entity/src/union.ts Dispatch map now registers members for all discriminant values (enum + multi-literal).
packages/entity/src/union.spec.ts Adds tests covering enum routing, input/make agreement, missing discriminant, multi-value literals.
packages/entity/src/freeze.spec.ts Adds regression tests for z.custom passthrough and ordinary object freezing.
packages/entity/src/equal.ts Introduces deepEqual structural equality implementation used by equals().
packages/entity/src/equal.spec.ts Adds test coverage for structural equality across supported stored types.
packages/entity/src/entity.ts Wires in deepEqual, fixes schema identity rebuilding, and adds schema-based passthrough freeze skipping.
packages/entity/src/contract.spec.ts Adds tests ensuring contract schema members are distinct and registry identity is stable.
docs/reference.md Updates equals documentation to reflect structural comparison semantics.
docs/explanation.md Clarifies freezing behavior and schema-based passthrough skipping rationale.
CLAUDE.md Updates architecture docs to include the new equal.ts module and freeze behavior note.
.changeset/fix-equality-union-freeze.md Adds a patch changeset documenting the fixed bugs and behavioral corrections.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/entity/src/equal.ts Outdated
Comment thread packages/entity/src/entity.ts Outdated
Both from review of the first pass.

`deepEqual` recursed with no cycle tracking, so two cyclic values died with
`RangeError: Maximum call stack size exceeded` -- the same escaping-throw
failure mode the module was written to remove, and reachable precisely because
this branch stopped freezing `z.custom` values, leaving them free to close a
loop. It now tracks pairs, keyed by the left value, so a revisited pair is
assumed equal (the co-inductive reading) while a difference reachable only
through a cycle is still found.

The passthrough skip compared top-level field schemas only, so a `z.custom`
nested inside a branded object, an array, a record or a tuple was still frozen
in place -- the caller's object, the same bug one level down. `deepFreeze` now
carries the schema alongside the value and consults it at every step, following
the single-child wrappers and treating a union as passthrough if any branch is.
Where a container cannot be followed the schema is simply absent and the walk
freezes as it always did, so an unhandled shape is a missed skip, never a crash.

`schema` sits after `seen` in the signature on purpose: typed `unknown`, in the
second position it silently swallowed the existing `deepFreeze(value, seen)`
calls, which type-checked and quietly stopped sharing the set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@btravers
btravers merged commit e13784e into main Aug 7, 2026
13 checks passed
@btravers
btravers deleted the fix/equality-union-freeze branch August 7, 2026 13:28
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.

2 participants