Skip to content

Commit 79cfede

Browse files
colinhacksclaude
andauthored
feat(v4): expose the owning schema on check-originated issues (#6420)
An error map could reach a schema's metadata only when the schema itself raised the issue. For the check-originated codes -- too_small, too_big, invalid_format, not_multiple_of -- issue.inst is the $ZodCheck, which has no meta() and no link back to the schema it was attached to. So the most common case, labelling a .min() failure with the field's own title, was not reachable. Adds an optional `schema` on the raw issue that always names the owning schema. runChecks stamps it onto the issues a check just raised, which is the only place the check and its schema are both in scope; it sits behind the existing `nextLen === currLen` early-continue, so a passing check adds nothing. finalizeIssue resolves it from `inst` for schemas that raised their own issue, which also outranks a stamp left by an enclosing check -- that is what keeps z.property() attributing to the inner schema. The $ZodType/$ZodCheck trait pair distinguishes the two, so nothing branches on a concrete schema type; it has to be the pair, since string formats and z.custom() are schema and check at once. The link is made at parse time, not at attach time: .meta() clones the schema and clones share check instances, so a back-pointer stored on the check would name the pre-metadata schema -- exactly wrong for z.string().min(5).meta({ title }), the case this is for. `schema` is stripped in finalizeIssue alongside `inst`, so it never reaches $ZodIssue. $ZodError.message is JSON.stringify over the issues, and a live schema there would serialize the whole graph and go circular on a recursive one. issue.inst keeps its current meaning. Bundle: +85 B gzip classic, +71 to +76 B gzip mini (+2.6% on the smallest mini fixture). Memory: unchanged, no own property added to any instance. Runtime: success path unchanged; a failing parse costs +2 to +3%. Closes #5240. Also covers #4681 and #5329. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 92f4798 commit 79cfede

5 files changed

Lines changed: 177 additions & 1 deletion

File tree

packages/docs/content/error-customization.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,26 @@ z.string({
157157
iss.code; // the issue code
158158
iss.input; // the input data
159159
iss.inst; // the schema/check that originated this issue
160+
iss.schema; // the schema that owns this issue
160161
iss.path; // the path of the error
161162
},
162163
});
163164
```
164165

166+
Unlike `iss.inst`, `iss.schema` is always the schema, even when a check originated the issue. Use it to read the owning schema's [metadata](/metadata).
167+
168+
```ts
169+
z.config({
170+
customError: (iss) => {
171+
const meta = iss.schema && z.globalRegistry.get(iss.schema);
172+
return `${meta?.title ?? "Field"} is invalid.`;
173+
},
174+
});
175+
176+
z.string().min(5).meta({ title: "Password" }).safeParse("abc");
177+
// => "Password is invalid."
178+
```
179+
165180
Depending on the API you are using, there may be additional properties available. Use TypeScript's autocomplete to explore the available properties.
166181

167182
```ts
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { afterEach, expect, test } from "vitest";
2+
import * as z from "zod/v4";
3+
4+
afterEach(() => {
5+
z.config({ customError: undefined });
6+
});
7+
8+
/** Collects the owning schema's registered metadata for every issue an error map sees. */
9+
function captureMeta(): { seen: (z.core.GlobalMeta | undefined)[] } {
10+
const seen: (z.core.GlobalMeta | undefined)[] = [];
11+
z.config({
12+
customError: (issue) => {
13+
seen.push(issue.schema && z.globalRegistry.get(issue.schema));
14+
return undefined;
15+
},
16+
});
17+
return { seen };
18+
}
19+
20+
test("check-originated issues expose the owning schema", () => {
21+
const { seen } = captureMeta();
22+
23+
z.object({ name: z.string().min(5).meta({ title: "Name" }) }).safeParse({ name: "ab" });
24+
z.string().max(2).meta({ title: "Max" }).safeParse("abc");
25+
z.email().meta({ title: "Email" }).safeParse("nope");
26+
z.number().multipleOf(3).meta({ title: "Multiple" }).safeParse(4);
27+
z.array(z.string()).min(2).meta({ title: "Array" }).safeParse([]);
28+
29+
expect(seen).toEqual([
30+
{ title: "Name" },
31+
{ title: "Max" },
32+
{ title: "Email" },
33+
{ title: "Multiple" },
34+
{ title: "Array" },
35+
]);
36+
});
37+
38+
test("schema-originated issues expose the owning schema", () => {
39+
const { seen } = captureMeta();
40+
41+
z.object({ name: z.string().min(5).meta({ title: "Name" }) }).safeParse({ name: 123 });
42+
z.email().meta({ title: "Email" }).safeParse(123);
43+
44+
expect(seen).toEqual([{ title: "Name" }, { title: "Email" }]);
45+
});
46+
47+
test("refinements attribute to the refined schema, not the internal check schema", () => {
48+
const { seen } = captureMeta();
49+
50+
z.string()
51+
.refine(() => false)
52+
.meta({ title: "Refine" })
53+
.safeParse("abc");
54+
z.string()
55+
.superRefine((_, ctx) => ctx.addIssue({ code: "custom", message: "" }))
56+
.meta({ title: "SuperRefine" })
57+
.safeParse("abc");
58+
59+
expect(seen).toEqual([{ title: "Refine" }, { title: "SuperRefine" }]);
60+
});
61+
62+
test("the innermost schema owns the issue", () => {
63+
const { seen } = captureMeta();
64+
65+
z.array(z.string().min(5).meta({ title: "Element" }))
66+
.meta({ title: "Array" })
67+
.safeParse(["ab"]);
68+
z.string()
69+
.pipe(z.string().min(5).meta({ title: "Target" }))
70+
.meta({ title: "Pipe" })
71+
.safeParse("ab");
72+
z.looseObject({})
73+
.check(z.property("a", z.string().meta({ title: "Property" })))
74+
.meta({ title: "Object" })
75+
.safeParse({ a: 1 });
76+
77+
expect(seen).toEqual([{ title: "Element" }, { title: "Target" }, { title: "Property" }]);
78+
});
79+
80+
test("issues carrying no usable `inst` still get the owning schema", () => {
81+
const { seen } = captureMeta();
82+
83+
// A hand-pushed issue may carry no inst at all, and ctx.addIssue(string) puts the check's def — not a Zod object — on inst.
84+
z.string()
85+
.check((payload) => {
86+
payload.issues.push({ code: "custom", input: payload.value });
87+
})
88+
.meta({ title: "Bare" })
89+
.safeParse("abc");
90+
const shorthand = z
91+
.string()
92+
.superRefine((_, ctx) => ctx.addIssue("bad stuff"))
93+
.meta({ title: "Shorthand" })
94+
.safeParse("abc");
95+
96+
expect(seen).toEqual([{ title: "Bare" }]);
97+
expect(shorthand.error!.issues[0].message).toEqual("bad stuff");
98+
});
99+
100+
test("async checks expose the owning schema", async () => {
101+
const { seen } = captureMeta();
102+
103+
await z
104+
.string()
105+
.refine(async () => false)
106+
.meta({ title: "Async" })
107+
.safeParseAsync("abc");
108+
109+
expect(seen).toEqual([{ title: "Async" }]);
110+
});
111+
112+
test("the owning schema is the clone `.meta()` registered, not the pre-metadata schema", () => {
113+
const bare = z.string().min(5);
114+
const labeled = bare.meta({ title: "Labeled" });
115+
const seen: unknown[] = [];
116+
z.config({
117+
customError: (issue) => {
118+
seen.push(issue.schema === labeled);
119+
return undefined;
120+
},
121+
});
122+
123+
labeled.safeParse("ab");
124+
125+
expect(seen).toEqual([true]);
126+
});
127+
128+
test("`inst` keeps its meaning and `schema` stays off the finalized issue", () => {
129+
const insts: string[] = [];
130+
z.config({
131+
customError: (issue) => {
132+
insts.push(issue.inst!.constructor.name);
133+
return undefined;
134+
},
135+
});
136+
137+
const tooSmall = z.string().min(5).safeParse("ab");
138+
const wrongType = z.string().safeParse(123);
139+
140+
expect(insts).toEqual(["$ZodCheckMinLength", "ZodString"]);
141+
expect(Object.keys(tooSmall.error!.issues[0])).not.toContain("schema");
142+
expect(Object.keys(wrongType.error!.issues[0])).not.toContain("schema");
143+
});

packages/zod/src/v4/core/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ type RawIssue<T extends $ZodIssueBase> = T extends any
202202
readonly input: unknown;
203203
/** The schema or check that originated this issue. */
204204
readonly inst?: $ZodType | $ZodCheck;
205+
/** The schema that owns the issue. Equal to `inst` when a schema originated the issue, and the schema the check was attached to when a check did. */
206+
readonly schema?: $ZodType | undefined;
205207
/** If `true`, Zod will continue executing checks/refinements after this issue. */
206208
readonly continue?: boolean | undefined;
207209
} & Record<string, unknown>

packages/zod/src/v4/core/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,13 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru
256256
await _;
257257
const nextLen = payload.issues.length;
258258
if (nextLen === currLen) return;
259+
util.attachSchema(payload.issues, currLen, inst);
259260
if (!isAborted) isAborted = util.aborted(payload, currLen);
260261
});
261262
} else {
262263
const nextLen = payload.issues.length;
263264
if (nextLen === currLen) continue;
265+
util.attachSchema(payload.issues, currLen, inst);
264266
if (!isAborted) isAborted = util.aborted(payload, currLen);
265267
}
266268
}

packages/zod/src/v4/core/util.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -847,11 +847,25 @@ export function unwrapMessage(message: string | { message: string } | undefined
847847
return typeof message === "string" ? message : message?.message;
848848
}
849849

850+
/* A check holds no link back to the schema it is attached to — the same check instance is shared by every clone of that schema — so the owner is stamped onto the issues a check just raised, at the only point where both are in scope. Runs on the failure path only; `start` is the issue count from before the check ran. */
851+
export function attachSchema(issues: errors.$ZodRawIssue[], start: number, inst: schemas.$ZodType): void {
852+
for (let i = start; i < issues.length; i++) {
853+
(issues[i] as any).schema ??= inst;
854+
}
855+
}
856+
850857
export function finalizeIssue(
851858
iss: errors.$ZodRawIssue,
852859
ctx: schemas.ParseContextInternal | undefined,
853860
config: $ZodConfig
854861
): errors.$ZodIssue {
862+
// A schema that raised an issue itself owns it outright, and outranks any stamp an enclosing check left in `attachSchema`. String formats and z.custom() are schema and check at once, so when they act as a check they defer to that stamp instead.
863+
const traits: Set<string> | undefined = (iss.inst as any)?._zod?.traits;
864+
if (traits?.has("$ZodType")) {
865+
if (traits.has("$ZodCheck")) (iss as any).schema ??= iss.inst;
866+
else (iss as any).schema = iss.inst;
867+
}
868+
855869
const message = iss.message
856870
? iss.message
857871
: (unwrapMessage(iss.inst?._zod.def?.error?.(iss as never)) ??
@@ -860,7 +874,7 @@ export function finalizeIssue(
860874
unwrapMessage(config.localeError?.(iss)) ??
861875
"Invalid input");
862876

863-
const { inst: _inst, continue: _continue, input: _input, ...rest } = iss as any;
877+
const { inst: _inst, schema: _schema, continue: _continue, input: _input, ...rest } = iss as any;
864878
rest.path ??= [];
865879
rest.message = message;
866880
if (ctx?.reportInput) {

0 commit comments

Comments
 (0)