diff --git a/.changeset/9491-walkabledef-rest-null.md b/.changeset/9491-walkabledef-rest-null.md new file mode 100644 index 0000000000..a3455ed458 --- /dev/null +++ b/.changeset/9491-walkabledef-rest-null.md @@ -0,0 +1,31 @@ +--- +'@object-ui/types': patch +--- + +Declare `WalkableDef.rest` as `z.ZodType | null` (objectui#9491). + +`packages/types/src/zod/node-derivation.ts` declares the def member set both zod walkers +in this package read. It declared `rest?: z.ZodType` — i.e. `z.ZodType | undefined` — while +zod 4 spells "this tuple has no rest element" as an OWN `rest` key holding `null`, minted by +`const rest = hasRest ? _paramsOrRest : null` in its `tuple` factory. + +**This is the declaration, not a behaviour change.** Nothing here changes what either walker +does with the value; the accept set of every exported schema is untouched. objectui#9088 +already repaired the one arm the inaccurate type misled — the `tuple` arm in +`zod/imported-defaults.ts`, which normalised the absent case to `undefined` because the +declared type said that was the absent case, and so rebuilt every rest-less tuple through a +`===` comparison that could never match. This change corrects the type that licensed it, so +the next arm written against it is told the truth and `tsc` agrees with the truth instead of +with the mistake. + +One read needed adjusting, contrary to the expectation the card recorded: the local +`unchanged` helper in `zod/imported-defaults.ts` declares its comparison pairs +`z.ZodType | undefined`, and the objectui#9088 repair hands it `def.rest` RAW — comparing +like with like is the whole of that repair. Its parameter now admits `null` too. The +comparison itself is still `===`: `null` matches only `null`, `undefined` only `undefined`. + +`WalkableDef` is emitted into the published `dist/` but is reachable through no entry in the +package `exports` map, so no consumer outside this package can name the widened member; the +new pin `types/src/__tests__/walkable-def-null-mint-9491.test.ts` re-derives against the +INSTALLED zod that `rest` is minted `null` and that no other member the walkers read ever is, +so a zod bump that moves either half goes red here. diff --git a/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts b/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts new file mode 100644 index 0000000000..85ec211a35 --- /dev/null +++ b/packages/types/src/__tests__/walkable-def-null-mint-9491.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE DECLARATION ADMITS WHAT ZOD ACTUALLY MINTS — AND NOTHING MORE (objectui#9491). + * + * `../zod/node-derivation.ts` declares `WalkableDef`, the def member set both + * walkers in this package read. Every member but one is declared + * `z.ZodType | undefined`, and for every member but one that is the truth. The + * exception is `rest`: zod 4 spells "this tuple has no rest element" as an OWN + * `rest` key holding `null`. + * + * ## Why a test rather than a comment + * + * The declaration used to say `rest?: z.ZodType`, and that is not a cosmetic + * drift — it is what licensed objectui#9088. The `tuple` arm in + * `../zod/imported-defaults.ts` normalised the absent case to `undefined` + * because the declared type said `undefined` was the absent case; `unchanged` + * compares by `===`; `null === undefined` is false; and so EVERY rest-less + * tuple was rebuilt. The author read the type, the type was wrong, and `tsc` + * agreed with them. A wrong type is more expensive than a wrong implementation + * because it makes the next author do every step right and still be wrong. + * + * ⇒ what has to stay true is an AGREEMENT between two things that move + * independently: a hand-written declaration in this repository, and a value + * minted inside an installed dependency. Prose cannot hold an agreement like + * that; only something that re-derives both halves can. So this file probes the + * INSTALLED zod rather than restating a version-stamped reading of it — a zod + * bump that moves either half reddens here instead of being rediscovered from + * a rebuilt schema months later. + * + * ## Both halves, because "only `rest`" is load-bearing + * + * 1. **`rest` IS minted `null`** — subject, plus the rest-BEARING control that + * fires the other way, so a green cannot come from the probe reading + * nothing. + * 2. **⭐ No other member is** — swept across the node kinds the walkers meet. + * Without this half, the honest repair for `rest` reads like a licence to + * spell `| null` on any member that looks similar, which would declare + * something zod does not do and reopen the same class from the other side. + * 3. **The declaration agrees with 1 and 2** — pinned through `tsc` (this + * package's `type-check` compiles `src/**\/*.test.ts` via + * `tsconfig.test.json`), so narrowing `rest` back, or widening a sibling to + * match it, fails to COMPILE rather than failing quietly. + * + * ⛔ Nothing here asserts walker BEHAVIOUR: the rest-less-tuple identity + * property and the rebuild half are `imported-defaults-rest-less-tuple-9088.test.ts`'s + * subject and stay there. This file is about the type telling the truth. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { internals, type WalkableDef } from '../zod/node-derivation.js'; + +/** + * Does `T` admit `null`? Resolved by the compiler; the runtime assertions below + * exist so the answer is also READ — an unused type alias proves nothing and a + * lint rule would be right to delete it. + */ +type AdmitsNull = null extends T ? true : false; + +/** Every member of `WalkableDef` — i.e. the whole surface the walkers read. */ +const WALKABLE_MEMBERS = [ + 'type', 'shape', 'options', 'items', 'element', 'rest', 'valueType', 'keyType', + 'left', 'right', 'in', 'out', 'innerType', 'catchall', 'getter', +] as const; + +const defOf = (node: z.ZodType): WalkableDef => internals(node)._zod.def; + +/** + * The node kinds these walkers can meet, each built the way a schema author + * would build it. ⚠️ The sweep below is only as wide as this matrix — so a node + * kind added to either walker's `switch` belongs here on the same change. + */ +const leaf = z.number(); +const MATRIX: Record = { + 'tuple (rest-less)': z.tuple([z.number(), z.number()]), + 'tuple (empty)': z.tuple([]), + 'tuple (with rest)': z.tuple([z.number()], z.string()), + object: z.object({ a: leaf }), + 'object (strict)': z.object({ a: leaf }).strict(), + 'object (loose)': z.object({ a: leaf }).loose(), + union: z.union([z.number(), z.string()]), + discriminatedUnion: z.discriminatedUnion('type', [ + z.object({ type: z.literal('a') }), + z.object({ type: z.literal('b') }), + ]), + array: z.array(leaf), + record: z.record(z.string(), leaf), + map: z.map(z.string(), leaf), + set: z.set(leaf), + intersection: z.intersection(z.object({ a: leaf }), z.object({ b: leaf })), + 'pipe (transform)': z.string().transform((s) => s.length), + 'pipe (preprocess)': z.preprocess((v) => v, z.string()), + lazy: z.lazy(() => leaf), + optional: z.optional(leaf), + nullable: z.nullable(leaf), + default: leaf.default(1), + prefault: leaf.prefault(1), + catch: leaf.catch(0), + readonly: z.object({ a: leaf }).readonly(), + nonoptional: z.optional(leaf).nonoptional(), + string: z.string(), + literal: z.literal('a'), + enum: z.enum(['a', 'b']), + never: z.never(), +}; + +/** `[member, node label]` for every walkable member observed holding `null`. */ +const nullMints = (): [string, string][] => { + const hits: [string, string][] = []; + for (const [label, node] of Object.entries(MATRIX)) { + const def = defOf(node) as unknown as Record; + for (const member of WALKABLE_MEMBERS) { + if (Object.hasOwn(def, member) && def[member] === null) hits.push([member, label]); + } + } + return hits; +}; + +describe('zod mints `null` into exactly one walkable def member (objectui#9491)', () => { + it('⭐ a rest-less tuple carries an OWN `rest` key holding `null`', () => { + const def = defOf(z.tuple([z.number(), z.number()])) as unknown as Record; + + // Three separate claims, and the fix rests on all three. An absent key, or + // an `undefined` value, would each make `: undefined` the correct spelling + // in the `tuple` arm again — and each would arrive without a symptom. + expect(Object.hasOwn(def, 'rest')).toBe(true); + expect(def.rest).toBeNull(); + expect(def.rest).not.toBeUndefined(); + + // PROVING REMOVAL: declare `rest` absent-as-undefined here (`def.rest` + // asserted `toBeUndefined`) and this reddens on the installed zod. + }); + + it('the control fires the other way: a tuple WITH a rest element holds a node there', () => { + const def = defOf(z.tuple([z.number()], z.string())); + + expect(def.rest).not.toBeNull(); + expect(def.rest && '_zod' in def.rest).toBe(true); + }); + + it('⭐ no OTHER member the walkers read is ever minted `null`', () => { + const members = [...new Set(nullMints().map(([member]) => member))].sort(); + + // The uniqueness claim, as a set rather than a count — a count says nothing + // about WHICH member moved, and which member moved is the entire question. + expect(members).toEqual(['rest']); + + // ⇒ if a later zod mints `null` into another member, this is the assertion + // that reddens, and the repair is to widen THAT member in `WalkableDef` and + // pin it below — ⛔ not to relax this expectation. + // + // PROVING REMOVAL: add `element` to the expected set and this reddens, + // which is what says the sweep is reading the matrix rather than a + // hard-coded answer. + }); + + it('⭐ every member observed holding `null` is minted by a tuple, not by a wrapper', () => { + // Guards the reading above against a matrix that happens to contain only + // tuples: the labels are carried so the source of each hit is named. + const labels = [...new Set(nullMints().map(([, label]) => label))].sort(); + + expect(labels).toEqual(['tuple (empty)', 'tuple (rest-less)']); + }); +}); + +describe('`WalkableDef` agrees with that mint, and the agreement is compiled (objectui#9491)', () => { + it('⭐ `rest` admits `null`', () => { + // ⛔ Not a restatement of the line in `node-derivation.ts`: `tsc` resolves + // `AdmitsNull` from the declaration itself, so narrowing `rest` back to + // `z.ZodType | undefined` makes this a type ERROR — the package's + // `type-check` compiles this file, so the error is a gate and not a + // suggestion. That compile failure is the real pin; the `expect` is here so + // the value is read. + const restAdmitsNull: AdmitsNull = true; + + expect(restAdmitsNull).toBe(true); + }); + + it('⭐ the siblings do NOT admit `null`, which is the half that keeps this honest', () => { + // `out` is named first because it is the near miss: a pipe's `out` holds an + // opaque transform, it sits one arm away from `rest` in both walkers, and + // the sweep above measures it clean. Copying `| null` onto it would declare + // an absent case zod never produces, and every read guarding against it + // would be dead code no test could ever reach. + const outAdmitsNull: AdmitsNull = false; + const elementAdmitsNull: AdmitsNull = false; + const itemsAdmitsNull: AdmitsNull = false; + + expect([outAdmitsNull, elementAdmitsNull, itemsAdmitsNull]).toEqual([false, false, false]); + }); +}); diff --git a/packages/types/src/zod/imported-defaults.ts b/packages/types/src/zod/imported-defaults.ts index 42765e66a0..2aab2660ab 100644 --- a/packages/types/src/zod/imported-defaults.ts +++ b/packages/types/src/zod/imported-defaults.ts @@ -158,9 +158,19 @@ const walk = (schema: z.ZodType): z.ZodType => { * the day `@objectstack/spec` adopts the same principle, with nothing to roll * back, and today it leaves every already-clean imported schema binding by * reference exactly as it was before batch #90. + * + * ⚠️ `| null` on the sides, because the `tuple` arm below hands this helper + * `def.rest` RAW — comparing like with like is the whole of objectui#9088's + * repair, and zod spells a rest-less tuple's `rest` as `null`. ⛔ NOT a + * relaxation of the comparison: it stays `===`, so `null` still matches only + * `null` and `undefined` still matches only `undefined`. Widening the + * parameter is what lets the arm keep passing the value zod actually minted + * instead of normalising it back into the objectui#9088 defect to satisfy a + * signature (objectui#9491). */ - const unchanged = (children: readonly (readonly [z.ZodType | undefined, z.ZodType | undefined])[]): boolean => - children.every(([before, after]) => before === after); + const unchanged = ( + children: readonly (readonly [z.ZodType | null | undefined, z.ZodType | null | undefined])[], + ): boolean => children.every(([before, after]) => before === after); let out: z.ZodType; switch (def.type) { @@ -237,10 +247,10 @@ const walk = (schema: z.ZodType): z.ZodType => { // `null == undefined` true for every arm at once and erase a real zod-4 // spelling distinction another arm may come to depend on. // - // ⚠️ `WalkableDef.rest` is declared `z.ZodType | undefined`, which does not - // admit the `null` zod actually mints — that inaccurate declaration is what - // made `: undefined` look correct. The value flows through untyped here; - // widening the shared type is objectui#9491. + // ⚠️ `WalkableDef.rest` is declared `z.ZodType | null` (objectui#9491) — + // it admits the `null` zod actually mints, so the value below is typed as + // what it is. Until that landed the declaration said `z.ZodType | + // undefined`, and that is what made `: undefined` look correct here. // // ⭐ PROVING REMOVAL: put `: undefined` back and // `__tests__/imported-defaults-rest-less-tuple-9088.test.ts` reddens on diff --git a/packages/types/src/zod/node-derivation.ts b/packages/types/src/zod/node-derivation.ts index 610c23e05c..7ddf40c50b 100644 --- a/packages/types/src/zod/node-derivation.ts +++ b/packages/types/src/zod/node-derivation.ts @@ -71,7 +71,30 @@ export interface WalkableDef { options?: z.ZodType[]; items?: z.ZodType[]; element?: z.ZodType; - rest?: z.ZodType; + /** + * ⭐ `| null`, and the `| null` is not decoration (objectui#9491). + * + * Zod 4 spells "this tuple has no rest element" as an OWN `rest` key holding + * `null`, minted by `const rest = hasRest ? _paramsOrRest : null` in its + * `tuple` factory — ⛔ not as an absent key, which is what `rest?: z.ZodType` + * alone claims. The inaccurate declaration is what licensed objectui#9088: + * the `tuple` arm in `./imported-defaults.ts` normalised the absent case to + * `undefined` because the type said that was the absent case, `unchanged` + * compares by `===`, and so every rest-less tuple was rebuilt. The author + * read the type, the type was wrong, and `tsc` agreed with them. + * + * ⛔ This is NOT a wider accept set: nothing here changes what the walkers + * do with the value. It admits the value zod was already putting there, so + * the next arm written against this type is told the truth. + * + * ⚠️ `rest` is the ONLY member of this interface with that property, and + * "only" is the load-bearing half — a sibling arm that copies this `| null` + * onto `out` or `element` would be declaring something zod does not do. + * `../__tests__/walkable-def-null-mint-9491.test.ts` re-derives BOTH halves + * against the installed zod — the `null` here, and its absence everywhere + * else — so a zod bump that moves either one goes red rather than quiet. + */ + rest?: z.ZodType | null; valueType?: z.ZodType; keyType?: z.ZodType; left?: z.ZodType;