Skip to content

finding(types): stripImportedDefaults rebuilds every rest-less tuple — def.rest is null, compared against undefined, so the identity property breaks #9088

Description

@os-warren

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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanepackage: typespm:queuepriority:p3

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions