Skip to content

Commit 4e1720c

Browse files
authored
fix(v4): align record keys and intersection strictness with TypeScript (#6412)
* fix(record): classify an out-of-set key by whether the key schema is enumerable An enumerable key schema (z.enum, z.literal([...])) declares which keys the record owns, so a key outside that set is unrecognized. A non-enumerable one (regex, refine) is a constraint every key must satisfy, so a failing key is invalid. Only the first is reconcilable against the other side of an intersection, which handleIntersectionResults already does for unrecognized_keys. The branch that emitted the issue keyed off def.partial rather than the key schema, so partialRecord(z.enum([...])) reported invalid_key and its keys were never reconciled -- the object side of an intersection could not claim its own keys. Fixes #2200, #2573. * fix(record): a record's key schema governs its own keys, not every key A record's key schema declares which keys that record is responsible for. It is not a predicate every key in the result must satisfy. TypeScript agrees: in `{name: string} & Record<\`S_\${string}\`, string>` the index signature constrains only the keys matching it, so `name` is accepted, while a matching key with the wrong value type is still an error. Zod's inferred types already match TypeScript here, but the runtime rejected the object's own keys with invalid_key, so z.infer accepted values the parser threw on. Two changes: - partialRecord with an enumerable key schema now reports an out-of-set key as unrecognized_keys, like z.record(z.enum([...])) already did. The branch keyed off def.partial rather than off the key schema. - handleIntersectionResults reconciles a record's invalid_key the same way it already reconciles unrecognized_keys, so a key one side does not govern is reported only when neither side governs it. Standalone records are unchanged: a key outside the key schema is still an error, matching TypeScript's excess-property check on a fresh literal. Fixes #2200, #2573. * fix(v4): let an unrecognized key continue, so operands run their own logic unrecognized_keys describes the shape of the input, not the validity of the parsed value, so it no longer aborts the schema it came from. A strict operand inside an intersection now runs its own checks, refinements and transforms, a nested default still applies, and an extra key no longer suppresses the other errors on the same object. The parse still fails. A pipe keeps its rule that any issue stops it, so a failing refinement never feeds its transform -- the one exception is unrecognized_keys, where the value handed downstream is identical whether or not the extra key was present.
1 parent 4cc4053 commit 4e1720c

3 files changed

Lines changed: 182 additions & 38 deletions

File tree

packages/zod/src/v4/classic/tests/intersection.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,121 @@ test("invalid deep merge of object and array combination", async () => {
214214
`[Error: Unmergable intersection. Error path: ["students",0,"name"]]`
215215
);
216216
});
217+
218+
// A record's key schema says which keys that record GOVERNS; it is not a predicate
219+
// every key in the result must satisfy. TypeScript works the same way: in
220+
// `{name: string} & Record<`S_${string}`, string>` the index signature constrains
221+
// only the keys matching it, so `name` is fine. So a key one side does not govern
222+
// is reconciled against the other side, exactly as unrecognized_keys already is.
223+
test("a record's key schema governs only its own keys inside an intersection", () => {
224+
const Obj = z.object({ name: z.string() });
225+
const value = { name: "a", S_a: "s" };
226+
227+
// Every key-schema flavor behaves the same: `name` belongs to the object side.
228+
expect(z.intersection(Obj, z.record(z.string().regex(/^S_/), z.string())).parse(value)).toEqual(value);
229+
expect(z.intersection(Obj, z.record(z.templateLiteral(["S_", z.string()]), z.string())).parse(value)).toEqual(value);
230+
expect(z.intersection(Obj, z.partialRecord(z.enum(["p1", "p2"]), z.string())).parse({ name: "a", p1: "x" })).toEqual({
231+
name: "a",
232+
p1: "x",
233+
});
234+
expect(z.intersection(Obj, z.record(z.enum(["p1"]), z.string())).parse({ name: "a", p1: "x" })).toEqual({
235+
name: "a",
236+
p1: "x",
237+
});
238+
239+
// A key the record DOES govern still has its value validated across the intersection.
240+
const governed = z.intersection(z.object({ S_x: z.number() }), z.record(z.string().regex(/^S_/), z.string()));
241+
expect(governed.safeParse({ S_x: 1 }).success).toBe(false);
242+
243+
// A key NEITHER side governs is still rejected.
244+
const strict = z.intersection(z.strictObject({ name: z.string() }), z.record(z.string().regex(/^S_/), z.string()));
245+
expect(strict.parse(value)).toEqual(value);
246+
expect(strict.safeParse({ ...value, evil: "q" }).success).toBe(false);
247+
248+
// Standalone, a record still rejects a key it does not govern.
249+
expect(z.record(z.string().regex(/^S_/), z.string()).safeParse({ S_a: "s", bad: "x" }).success).toBe(false);
250+
});
251+
252+
test("partialRecord reports an out-of-set key as unrecognized, not invalid", () => {
253+
const enumKeys = z.partialRecord(z.enum(["a", "b"]), z.string()).safeParse({ a: "x", zzz: "q" });
254+
expect(enumKeys.success).toBe(false);
255+
expect(enumKeys.error!.issues[0].code).toBe("unrecognized_keys");
256+
257+
// A regex key schema still reports the failure as an invalid key.
258+
const regexKeys = z.record(z.string().regex(/^S_/), z.string()).safeParse({ S_a: "x", zzz: "q" });
259+
expect(regexKeys.success).toBe(false);
260+
expect(regexKeys.error!.issues[0].code).toBe("invalid_key");
261+
});
262+
263+
test("intersection operands run their own checks, refinements and transforms", () => {
264+
let checks = 0;
265+
const failing = z.intersection(
266+
z.strictObject({ x: z.string() }).check(() => {
267+
checks++;
268+
}),
269+
z.strictObject({ a: z.string() }).superRefine((_data, ctx) => {
270+
checks++;
271+
ctx.addIssue({ code: "custom", message: "boom" });
272+
})
273+
);
274+
expect(failing.safeParse({ x: "test", a: "hello" })).toMatchObject({
275+
success: false,
276+
error: { issues: [{ code: "custom", message: "boom" }] },
277+
});
278+
expect(checks).toBe(2);
279+
280+
const transformed = z.intersection(
281+
z.strictObject({ x: z.string() }).transform((v) => ({ ...v, x: v.x.toUpperCase() })),
282+
z.strictObject({ a: z.string() }).transform((v) => ({ ...v, seen: true }))
283+
);
284+
expect(transformed.