stripImportedDefaults rebuilds every rest-less tuple, whether or not anything beneath it changed — breaking the identity property the module states about itself.
The identity property this violates
packages/types/src/zod/imported-defaults.ts states it in the walker's own docblock:
A node is rebuilt ONLY if the walk actually changed something beneath it. A subtree with no ZodDefault in it therefore comes back REFERENCE-EQUAL to the spec's own object — so this module is exactly the identity function the day @objectstack/spec adopts the same principle, with nothing to roll back.
That is batch #90's reversibility argument made literal, and it is why option A was taken over option B. A rest-less tuple breaks it.
The mechanism, from source
The tuple arm:
case 'tuple': {
const items = (def.items ?? []).map(walk);
const rest = def.rest ? walk(def.rest) : undefined;
out = unchanged([...(def.items ?? []).map((it, i) => [it, items[i]] as const), [def.rest, rest] as const])
? schema
: cloneWithDef(schema, { items, ...(def.rest ? { rest: rest! } : {}) });
break;
}
Zod 4.4.3 spells "no rest element" as def.rest === null, not as an absent key. The arm's own def.rest ? walk(def.rest) : undefined produces undefined. unchanged then compares the pair by ===:
null === undefined -> false
So unchanged is false for every rest-less tuple regardless of its items, and the arm always takes the cloneWithDef branch. Measured on the node this was found through — RangeOperatorSchema.$between's inner tuple:
tuple def keys : ["type","items","rest"]
def.rest value : null
def.rest === null : true
def.rest === undefined: false
arm compares [def.rest, rest] -> null === undefined ? false
Reproduction, two lines
stripImportedDefaults(z.tuple([z.number(), z.number()])) === z.tuple(...) // false — rebuilt
Run against the walker as it stands on origin/main (commit f1190b0), with controls that fire the other way:
| probe |
identity holds? |
z.tuple([z.number(), z.number()]) — no rest element |
false |
z.tuple([z.number()], z.string()) — has a rest element |
true |
z.object({ a: z.string() }) — no tuple at all |
true |
The second and third rows are the control: the break is specific to the rest-less tuple, not a general failure of the identity property.
Blast radius on the published spec surface
Walking every schema-shaped export of every module subpath of @objectstack/spec 17.4.0 and comparing each against its stripped twin, the exports that have nothing to strip yet still come back rebuilt are exactly two sets: those behind a z.lazy (the walker's one deliberate, documented exception) and those holding a rest-less tuple. The latter set is non-empty and includes:
@objectstack/spec/data#FieldOperatorsSchema
@objectstack/spec/data#RangeOperatorSchema
@objectstack/spec/ui#ListMapConfigSchema
For each, the differing node is the tuple under $between / center.
Why this is its own card and not a rider
Found while pinning the identity property for objectui#9034 (PR objectui#9086) and deliberately not fixed there. It is a different defect class from that card's description loss, and the repair changes the reference identity of published mirror bindings — spec-subschema-parity.test.ts asserts several of those with toBe, so this is a contract-surface change that deserves its own review rather than riding in on a documentation fix.
Candidate fix, not yet probed
Compare like with like instead of normalising one side to undefined — e.g. const rest = def.rest ? walk(def.rest) : def.rest; so a null compares against null. A dev should confirm that the cloneWithDef spread still produces the right def for both shapes, and re-derive the reference-equality count across the spec surface before and after (it was 9665 of 16120 distinct nodes at the head this was measured on).
Already carved out, and the carve-out expires loudly
packages/types/src/__tests__/imported-defaults-describe-9034.test.ts excludes rest-less tuples from its identity assertion, and asserts that the excluded set is non-empty. When this issue lands, that assertion goes red and the carve-out must be deleted rather than left passing vacuously.
Generated by Claude Code
stripImportedDefaultsrebuilds every rest-less tuple, whether or not anything beneath it changed — breaking the identity property the module states about itself.The identity property this violates
packages/types/src/zod/imported-defaults.tsstates it in the walker's own docblock:That is batch #90's reversibility argument made literal, and it is why option A was taken over option B. A rest-less tuple breaks it.
The mechanism, from source
The
tuplearm:Zod 4.4.3 spells "no rest element" as
def.rest === null, not as an absent key. The arm's owndef.rest ? walk(def.rest) : undefinedproducesundefined.unchangedthen compares the pair by===:So
unchangedis false for every rest-less tuple regardless of its items, and the arm always takes thecloneWithDefbranch. Measured on the node this was found through —RangeOperatorSchema.$between's inner tuple:Reproduction, two lines
Run against the walker as it stands on
origin/main(commitf1190b0), with controls that fire the other way:z.tuple([z.number(), z.number()])— no rest elementz.tuple([z.number()], z.string())— has a rest elementz.object({ a: z.string() })— no tuple at allThe second and third rows are the control: the break is specific to the rest-less tuple, not a general failure of the identity property.
Blast radius on the published spec surface
Walking every schema-shaped export of every module subpath of
@objectstack/spec17.4.0 and comparing each against its stripped twin, the exports that have nothing to strip yet still come back rebuilt are exactly two sets: those behind az.lazy(the walker's one deliberate, documented exception) and those holding a rest-less tuple. The latter set is non-empty and includes:@objectstack/spec/data#FieldOperatorsSchema@objectstack/spec/data#RangeOperatorSchema@objectstack/spec/ui#ListMapConfigSchemaFor each, the differing node is the tuple under
$between/center.Why this is its own card and not a rider
Found while pinning the identity property for objectui#9034 (PR objectui#9086) and deliberately not fixed there. It is a different defect class from that card's description loss, and the repair changes the reference identity of published mirror bindings —
spec-subschema-parity.test.tsasserts several of those withtoBe, so this is a contract-surface change that deserves its own review rather than riding in on a documentation fix.Candidate fix, not yet probed
Compare like with like instead of normalising one side to
undefined— e.g.const rest = def.rest ? walk(def.rest) : def.rest;so anullcompares againstnull. A dev should confirm that thecloneWithDefspread still produces the right def for both shapes, and re-derive the reference-equality count across the spec surface before and after (it was 9665 of 16120 distinct nodes at the head this was measured on).Already carved out, and the carve-out expires loudly
packages/types/src/__tests__/imported-defaults-describe-9034.test.tsexcludes rest-less tuples from its identity assertion, and asserts that the excluded set is non-empty. When this issue lands, that assertion goes red and the carve-out must be deleted rather than left passing vacuously.Generated by Claude Code