close
Skip to content

Commit 326276c

Browse files
committed
feat(v4): add deepPartial + mapOnSchema traversal primitive (colinhacks#2854, colinhacks#2106)
`.deepPartial()` was deprecated in v3.21.0 and removed in v4 because the underlying recursive `instanceof` traversal had structural problems (see colinhacks#2106): it could not see user-defined schema subclasses, accumulated edge cases for every new schema type, and silently broke on advanced shapes. The community has been asking for a working alternative ever since (colinhacks#2854, 60+ commenters). Adds a v4-native implementation that addresses the design concerns of colinhacks#2106 head-on: - Dispatches on `schema._zod.def.type`, not `instanceof`. Custom schemas with unknown `def.type` fall through to identity instead of being silently mishandled. Users intercept inside `fn` for custom rewriting. - Cycle-safe via a `Map<schema, RESOLVING|result>` cache. Lazy schemas unfold lazily so recursive shapes terminate; shared sub-schemas are visited once. Direct (non-`lazy`) cycles throw rather than loop. - Bottom-up rewrite as a primitive. `core.mapOnSchema(schema, fn)` is the general traversal; `deepPartial` is one line on top of it. `deepStrict`, `deepReadonly`, etc. become trivial follow-ups. - Covers the v4 def vocabulary: object, array, tuple (+rest), record, map, set, union (incl. discriminated union — discriminator metadata preserved by spreading `def`), intersection, optional, nullable, default, prefault, nonoptional, catch, readonly, promise, success, pipe, function, lazy. Leaves (primitives, enum, literal, transform, custom, file, etc.) returned untouched. `mapOnSchema` lives in `core/` so both `classic` and `mini` reuse it; each variant has its own thin `deepPartial` wired to its own `partial`. Traversal pattern adapted from Jaen's v3 `mapOnSchema` (Apache-2.0, https://gist.github.com/jaens/7e15ae1984bb338c86eb5e452dee3010). Implementation rewritten for v4: `def.type` dispatch instead of `instanceof`, schema construction via `core.clone` instead of `new ZodFoo(...)`, full v4 def coverage. ```ts const Node: z.ZodType = z.object({ name: z.string(), children: z.array(z.lazy(() => Node)).optional(), }); z.deepPartial(Node).parse({}); // ok z.deepPartial(Node).parse({ children: [{}] }); // ok ``` Adds 22 tests covering shallow + nested objects, arrays, tuples with rest, unions, optional/nullable wrappers, recursive (lazy) schemas, primitive leaf preservation, and `mapOnSchema` identity / targeted rewrites. CI on TS 6+ may surface the unrelated `baseUrl` deprecation error from colinhacks#5921 until that lands; this PR doesn't touch that path.
1 parent c59d447 commit 326276c

7 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as core from "../core/index.js";
2+
import * as schemas from "./schemas.js";
3+
4+
type DeepPartialValue<T> = T extends Date | RegExp | ((...args: any[]) => any)
5+
? T
6+
: T extends ReadonlyArray<infer U>
7+
? T extends unknown[]
8+
? DeepPartialValue<U>[]
9+
: ReadonlyArray<DeepPartialValue<U>>
10+
: T extends object
11+
? { [K in keyof T]?: DeepPartialValue<T[K]> }
12+
: T;
13+
14+
/**
15+
* Returns a schema where every nested object's properties are made
16+
* optional, recursively. Built on {@link core.mapOnSchema}; terminates
17+
* on lazy cycles and visits shared sub-schemas once. Non-object nodes
18+
* (primitives, transforms, refinements, etc.) are left untouched.
19+
*/
20+
export function deepPartial<T extends core.$ZodType>(
21+
schema: T
22+
): schemas.ZodType<DeepPartialValue<core.output<T>>, DeepPartialValue<core.input<T>>> {
23+
return core.mapOnSchema(schema, (s) => {
24+
const def = s._zod.def as any;
25+
if (def.type === "object") return (s as schemas.ZodObject).partial();
26+
// Discriminated unions: making the discriminator optional collapses
27+
// the fast-path lookup (duplicate `undefined` discriminator). Fall
28+
// back to a plain `union` over the already-partialed options, which
29+
// still validates correctly via try-each.
30+
if (def.type === "union" && def.discriminator !== undefined) {
31+
return schemas.union(def.options);
32+
}
33+
return s;
34+
}) as any;
35+
}

‎packages/zod/src/v4/classic/external.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export {
3232
} from "../core/index.js";
3333
export { toJSONSchema } from "../core/json-schema-processors.js";
3434
export { fromJSONSchema } from "./from-json-schema.js";
35+
export { deepPartial } from "./deep-partial.js";
36+
export { mapOnSchema } from "../core/map-on-schema.js";
3537

3638
export * as locales from "../locales/index.js";
3739

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { describe, expect, expectTypeOf, test } from "vitest";
2+
import * as z from "zod";
3+
import * as core from "../../core/index.js";
4+
import { deepPartial } from "../deep-partial.js";
5+
6+
const { mapOnSchema } = core;
7+
8+
describe("deepPartial", () => {
9+
test("makes shallow object optional", () => {
10+
const schema = z.object({ a: z.string(), b: z.number() });
11+
const partial = deepPartial(schema);
12+
expect(partial.parse({})).toEqual({});
13+
expect(partial.parse({ a: "x" })).toEqual({ a: "x" });
14+
expect(partial.parse({ a: "x", b: 1 })).toEqual({ a: "x", b: 1 });
15+
});
16+
17+
test("makes nested object optional at every level", () => {
18+
const schema = z.object({
19+
name: z.string(),
20+
profile: z.object({
21+
bio: z.string(),
22+
age: z.number(),
23+
}),
24+
});
25+
const partial = deepPartial(schema);
26+
expect(partial.parse({})).toEqual({});
27+
expect(partial.parse({ profile: {} })).toEqual({ profile: {} });
28+
expect(partial.parse({ profile: { bio: "x" } })).toEqual({ profile: { bio: "x" } });
29+
});
30+
31+
test("recurses through arrays", () => {
32+
const schema = z.object({ items: z.array(z.object({ a: z.string(), b: z.number() })) });
33+
const partial = deepPartial(schema);
34+
expect(partial.parse({ items: [{}, { a: "x" }] })).toEqual({ items: [{}, { a: "x" }] });
35+
});
36+
37+
test("recurses through tuples and rest", () => {
38+
const schema = z.tuple([z.object({ a: z.string() }), z.object({ b: z.number() })], z.object({ c: z.boolean() }));
39+
const partial = deepPartial(schema);
40+
expect(partial.parse([{}, {}])).toEqual([{}, {}]);
41+
expect(partial.parse([{}, {}, {}])).toEqual([{}, {}, {}]);
42+
});
43+
44+
test("recurses through unions", () => {
45+
const schema = z.union([z.object({ a: z.string() }), z.object({ b: z.number() })]);
46+
const partial = deepPartial(schema);
47+
expect(partial.parse({})).toEqual({});
48+
});
49+
50+
test("recurses through optional/nullable wrappers", () => {
51+
const schema = z.object({ x: z.object({ a: z.string() }).optional() });
52+
const partial = deepPartial(schema);
53+
expect(partial.parse({})).toEqual({});
54+
expect(partial.parse({ x: {} })).toEqual({ x: {} });
55+
});
56+
57+
test("terminates on recursive (lazy) schemas", () => {
58+
const Node: z.ZodType = z.object({
59+
name: z.string(),
60+
children: z.array(z.lazy(() => Node)).optional(),
61+
});
62+
const partial = deepPartial(Node);
63+
expect(partial.parse({})).toEqual({});
64+
expect(partial.parse({ name: "root", children: [{ name: "child" }] })).toEqual({
65+
name: "root",
66+
children: [{ name: "child" }],
67+
});
68+
expect(partial.parse({ children: [{}] })).toEqual({ children: [{}] });
69+
});
70+
71+
test("preserves primitives at leaves", () => {
72+
const schema = z.object({ a: z.string().min(1), b: z.number().int() });
73+
const partial = deepPartial(schema);
74+
expect(() => partial.parse({ a: "" })).toThrow();
75+
expect(() => partial.parse({ b: 1.5 })).toThrow();
76+
expect(partial.parse({ a: "ok", b: 1 })).toEqual({ a: "ok", b: 1 });
77+
});
78+
79+
test("keyof preserved on output type", () => {
80+
const schema = z.object({
81+
name: z.string(),
82+
profile: z.object({ bio: z.string() }),
83+
});
84+
type Out = z.output<typeof schema> extends infer X ? X : never;
85+
const partial = deepPartial(schema);
86+
type PartialOut = z.output<typeof partial>;
87+
expectTypeOf<keyof PartialOut>().toEqualTypeOf<keyof Out>();
88+
});
89+
90+
test("3-level deep nesting", () => {
91+
const schema = z.object({
92+
a: z.object({
93+
b: z.object({
94+
c: z.object({ d: z.string() }),
95+
}),
96+
}),
97+
});
98+
const partial = deepPartial(schema);
99+
expect(partial.parse({})).toEqual({});
100+
expect(partial.parse({ a: {} })).toEqual({ a: {} });
101+
expect(partial.parse({ a: { b: { c: {} } } })).toEqual({ a: { b: { c: {} } } });
102+
});
103+
104+
test("discriminated union: degrades to plain union (discriminator becomes optional)", () => {
105+
const schema = z.discriminatedUnion("kind", [
106+
z.object({ kind: z.literal("a"), a: z.string() }),
107+
z.object({ kind: z.literal("b"), b: z.number() }),
108+
]);
109+
const partial = deepPartial(schema);
110+
expect(partial.parse({ kind: "a", a: "x" })).toEqual({ kind: "a", a: "x" });
111+
expect(partial.parse({ kind: "b", b: 1 })).toEqual({ kind: "b", b: 1 });
112+
// With discriminator partial, an empty object matches the first option.
113+
expect(partial.parse({})).toEqual({});
114+
});
115+
116+
test("intersection", () => {
117+
const schema = z.intersection(z.object({ a: z.string() }), z.object({ b: z.number() }));
118+
const partial = deepPartial(schema);
119+
expect(partial.parse({})).toEqual({});
120+
expect(partial.parse({ a: "x" })).toEqual({ a: "x" });
121+
expect(partial.parse({ b: 1 })).toEqual({ b: 1 });
122+
});
123+
124+
test("record / map / set value schemas are recursed", () => {
125+
const rec = deepPartial(z.record(z.string(), z.object({ a: z.string() })));
126+
expect(rec.parse({ k: {} })).toEqual({ k: {} });
127+
128+
const m = deepPartial(z.map(z.string(), z.object({ a: z.string() })));
129+
expect(m.parse(new Map([["k", {}]]))).toEqual(new Map([["k", {}]]));
130+
131+
const s = deepPartial(z.set(z.object({ a: z.string() })));
132+
expect(s.parse(new Set([{}]))).toEqual(new Set([{}]));
133+
});
134+
135+
test("wrapper composition (optional + default + readonly)", () => {
136+
const schema = z.object({
137+
x: z
138+
.object({ a: z.string() })
139+
.default({ a: "hi" })
140+
.readonly(),
141+
});
142+
const partial = deepPartial(schema);
143+
expect(partial.parse({})).toEqual({ x: { a: "hi" } });
144+
expect(partial.parse({ x: {} })).toEqual({ x: {} });
145+
});
146+
147+
test("transform standalone is left untouched", () => {
148+
const schema = z.object({ a: z.string().transform((s) => s.length) });
149+
const partial = deepPartial(schema);
150+
expect(partial.parse({ a: "hi" })).toEqual({ a: 2 });
151+
expect(partial.parse({})).toEqual({});
152+
});
153+
154+
test("pipe input and output are both recursed", () => {
155+
const schema = z
156+
.object({ a: z.string() })
157+
.pipe(z.object({ a: z.string() }));
158+
const partial = deepPartial(schema);
159+
// Both sides of the pipe should accept missing fields.
160+
expect(partial.parse({})).toEqual({});
161+
});
162+
});
163+
164+
describe("mapOnSchema", () => {
165+
test("identity rewrite preserves parse semantics", () => {
166+
const schema = z.object({ a: z.string(), nested: z.object({ b: z.number() }) });
167+
const same = mapOnSchema(schema, (s) => s);
168+
expect(same.parse({ a: "x", nested: { b: 1 } })).toEqual({ a: "x", nested: { b: 1 } });
169+
});
170+
171+
test("can target a specific def.type", () => {
172+
const schema = z.object({ a: z.string(), b: z.number() });
173+
const allStrings = mapOnSchema(schema, (s) => (s._zod.def.type === "number" ? z.string() : s));
174+
expect(allStrings.parse({ a: "x", b: "y" } as any)).toEqual({ a: "x", b: "y" });
175+
});
176+
177+
test("visits shared sub-schema only once", () => {
178+
// schema has 3 unique nodes: schema, inner (referenced twice), inner.shape.a (z.string()).
179+
const inner = z.object({ a: z.string() });
180+
const schema = z.object({ x: inner, y: inner });
181+
let calls = 0;
182+
mapOnSchema(schema, (s) => {
183+
calls++;
184+
return s;
185+
});
186+
expect(calls).toBe(3);
187+
});
188+
189+
test("unknown def.type falls through unchanged", () => {
190+
const customSchema: any = z.string();
191+
customSchema._zod.def = { ...customSchema._zod.def, type: "myCustomType" };
192+
const result = mapOnSchema(customSchema, (s) => s);
193+
expect(result).toBe(customSchema);
194+
});
195+
});
196+

‎packages/zod/src/v4/core/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from "./to-json-schema.js";
1414
export { toJSONSchema } from "./json-schema-processors.js";
1515
export { JSONSchemaGenerator } from "./json-schema-generator.js";
1616
export * as JSONSchema from "./json-schema.js";
17+
export { mapOnSchema } from "./map-on-schema.js";
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Traversal pattern adapted from Jaen's v3 `mapOnSchema` (Apache-2.0):
2+
// https://gist.github.com/jaens/7e15ae1984bb338c86eb5e452dee3010
3+
4+
import { clone } from "./util.js";
5+
import type * as schemas from "./schemas.js";
6+
7+
const RESOLVING: unique symbol = Symbol("mapOnSchema/resolving");
8+
type Resolving = typeof RESOLVING;
9+
10+
type AnyZod = schemas.$ZodType;
11+
12+
/**
13+
* Bottom-up rewrite of a schema tree. `fn` runs on every node after its
14+
* children have been mapped; the returned schema replaces the node.
15+
* Shared sub-schemas are visited once. Direct (non-`lazy`) cycles throw.
16+
*
17+
* Dispatches on `_zod.def.type`, not `instanceof`, so user-defined
18+
* schema types pass through. Override custom types from inside `fn`.
19+
*/
20+
export function mapOnSchema<T extends AnyZod>(schema: T, fn: (s: AnyZod) => AnyZod): T {
21+
const cache = new Map<AnyZod, AnyZod | Resolving>();
22+
23+
function visit(s: AnyZod): AnyZod {
24+
const cached = cache.get(s);
25+
if (cached === RESOLVING) {
26+
throw new Error("mapOnSchema: encountered a non-lazy schema cycle");
27+
}
28+
if (cached !== undefined) return cached;
29+
cache.set(s, RESOLVING);
30+
const mapped = fn(mapInner(s));
31+
cache.set(s, mapped);
32+
return mapped;
33+
}
34+
35+
function patch(s: AnyZod, p: object): AnyZod {
36+
return clone(s, { ...s._zod.def, ...p });
37+
}
38+
39+
function mapInner(s: AnyZod): AnyZod {
40+
const def = s._zod.def as any;
41+
switch (def.type as schemas.$ZodTypeDef["type"]) {
42+
case "object": {
43+
const oldShape = def.shape as Record<string, AnyZod>;
44+
const newShape: Record<string, AnyZod> = {};
45+
for (const k in oldShape) newShape[k] = visit(oldShape[k]!);
46+
return patch(s, { shape: newShape });
47+
}
48+
case "array":
49+
return patch(s, { element: visit(def.element) });
50+
case "tuple": {
51+
const items = (def.items as AnyZod[]).map(visit);
52+
const rest = def.rest ? visit(def.rest) : def.rest;
53+
return patch(s, { items, rest });
54+
}
55+
case "record":
56+
case "map":
57+
return patch(s, { keyType: visit(def.keyType), valueType: visit(def.valueType) });
58+
case "set":
59+
return patch(s, { valueType: visit(def.valueType) });
60+
case "union":
61+
return patch(s, { options: (def.options as AnyZod[]).map(visit) });
62+
case "intersection":
63+
return patch(s, { left: visit(def.left), right: visit(def.right) });
64+
case "optional":
65+
case "nullable":
66+
case "default":
67+
case "prefault":
68+
case "catch":
69+
case "readonly":
70+
case "nonoptional":
71+
case "promise":
72+
case "success":
73+
return patch(s, { innerType: visit(def.innerType) });
74+
case "pipe":
75+
return patch(s, { in: visit(def.in), out: visit(def.out) });
76+
case "function":
77+
return patch(s, { input: visit(def.input), output: visit(def.output) });
78+
case "lazy": {
79+
const original = def.getter as () => AnyZod;
80+
return patch(s, { getter: () => visit(original()) });
81+
}
82+
// Leaves: nothing to recurse into.
83+
default:
84+
return s;
85+
}
86+
}
87+
88+
return fn(mapInner(schema)) as T;
89+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import * as core from "../core/index.js";
2+
import * as schemas from "./schemas.js";
3+
4+
type DeepPartialValue<T> = T extends Date | RegExp | ((...args: any[]) => any)
5+
? T
6+
: T extends ReadonlyArray<infer U>
7+
? T extends unknown[]
8+
? DeepPartialValue<U>[]
9+
: ReadonlyArray<DeepPartialValue<U>>
10+
: T extends object
11+
? { [K in keyof T]?: DeepPartialValue<T[K]> }
12+
: T;
13+
14+
/** See `classic/deep-partial.ts`. Mini variant uses `mini.partial(...)`. */
15+
export function deepPartial<T extends core.$ZodType>(
16+
schema: T
17+
): schemas.ZodMiniType<DeepPartialValue<core.output<T>>, DeepPartialValue<core.input<T>>> {
18+
return core.mapOnSchema(schema, (s) => {
19+
const def = s._zod.def as any;
20+
if (def.type === "object") return schemas.partial(s as schemas.ZodMiniObject);
21+
if (def.type === "union" && def.discriminator !== undefined) {
22+
return schemas.union(def.options);
23+
}
24+
return s;
25+
}) as any;
26+
}

‎packages/zod/src/v4/mini/external.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export * as core from "../core/index.js";
22
export * from "./parse.js";
33
export * from "./schemas.js";
44
export * from "./checks.js";
5+
export { deepPartial } from "./deep-partial.js";
6+
export { mapOnSchema } from "../core/map-on-schema.js";
57

68
export type { infer, output, input } from "../core/index.js";
79
export type { JSONType } from "../core/util.js";

0 commit comments

Comments
 (0)