Skip to content

Commit 3c2dee9

Browse files
authored
Add properties checks for instanceof schemas (#5912)
* Add properties checks for instanceof schemas * Revert "Add properties checks for instanceof schemas" Replaced by a plain z.properties(shape) helper that returns an array of ordinary $ZodCheckProperty checks. The dedicated $ZodCheckProperties check type had no case in core/compile.ts, so any schema using it dropped off the AOT fast path, and SupportedCheck there is a hand-listed union so nothing caught the omission. * Add z.properties() for checking several properties at once Returns an array of ordinary $ZodCheckProperty checks to spread into the existing variadic .check(), so there is no new check type, no new constructor, and no per-schema-type method. Each check carries `when`, which opts it out of the run loop's aborted-issue gate, so a parse reports every property that failed rather than only the first. * fix(v4): drop the always-run gate from z.properties `when` opts a check past the *implicit* abort gate, not just the sibling-property one. `runChecks` skips a `when`-carrying check only on `util.explicitlyAborted`, but `z.object` / `z.record` / `z.array` push their type-mismatch issues with no `continue` field, so the property checks ran against a value the base schema had already rejected — and `$ZodCheckProperty` indexes `payload.value` unguarded. const obj = z.object({ a: z.string() }).check(...z.properties({ a: z.literal("x") })); obj.safeParse(null); // TypeError: Cannot read properties of null (reading 'a') obj.safeParse(5); // invalid_type [] plus a spurious invalid_value ["a"] A throwing `safeParse` is not a trade worth making for aggregated issue paths, so the checks are now plain: `z.properties(shape)` is exactly the array of `z.property()` calls it looks like, and behaves identically to writing them out. That also puts the schema back on the compiled fast path, since `compile.ts` rejects any check carrying a non-defaulted `when`. The z.instanceof tests missed this because `_instanceof` aborts explicitly. The object cases now cover null, undefined and a primitive. * test(v4): pin where z.properties is looser than the longhand Every element is typed over the whole shape, and `$ZodCheckInternals.check()` is a method, so TypeScript compares it bivariantly and accepts a check type that is a subtype of the target. Naming a key the target lacks compiles as a result, and fails at parse time instead — where the equivalent chain of `z.property()` calls rejects it outright. Typing each element over only its own key restores the diagnostic and breaks every valid call, because a union argument has to satisfy the target on its own. Pinning the gap so it reads as a known trade rather than an accident.
1 parent 87ffeb0 commit 3c2dee9

6 files changed

Lines changed: 78 additions & 0 deletions

File tree

packages/docs/content/api.mdx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2210,6 +2210,20 @@ blobSchema.parse("hello there!"); // ✅
22102210
blobSchema.parse("hello."); //
22112211
```
22122212

2213+
Use `z.properties()` to declare several property checks from an object literal. It returns an array to spread into `.check()`.
2214+
2215+
```ts
2216+
const httpsUrl = z.instanceof(URL).check(
2217+
...z.properties({
2218+
protocol: z.literal("https:" as string),
2219+
hostname: z.string().regex(z.regexes.domain),
2220+
})
2221+
);
2222+
2223+
httpsUrl.parse(new URL("https://example.com")); //
2224+
httpsUrl.parse(new URL("http://localhost")); // ❌ protocol
2225+
```
2226+
22132227
## Refinements
22142228

22152229
Every Zod schema stores an array of *refinements*. Refinements are a way to perform custom validation that Zod doesn't provide a native API for.

packages/zod/src/v4/classic/checks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
_startsWith as startsWith,
2222
_endsWith as endsWith,
2323
_property as property,
24+
_properties as properties,
2425
_mime as mime,
2526
_overwrite as overwrite,
2627
_normalize as normalize,

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,50 @@ test("instanceof respects customError", () => {
5858

5959
z.config({ customError: undefined });
6060
});
61+
62+
test("z.properties", () => {
63+
const httpsUrl = z.instanceof(URL).check(
64+
...z.properties({
65+
protocol: z.literal("https:" as string),
66+
hostname: z.string().regex(z.regexes.domain),
67+
})
68+
);
69+
70+
expectTypeOf<URL>().toEqualTypeOf<z.infer<typeof httpsUrl>>();
71+
expect(httpsUrl.safeParse(new URL("https://example.com")).success).toBe(true);
72+
73+
// Checks abort on the first failure, exactly as the equivalent chain of z.property() calls does.
74+
const both = httpsUrl.safeParse(new URL("http://localhost"));
75+
expect(both.error!.issues.map((i) => i.path)).toEqual([["protocol"]]);
76+
77+
// A failing base schema yields its own issue and no property issues.
78+
for (const input of ["not a url", null]) {
79+
const issues = httpsUrl.safeParse(input).error!.issues;
80+
expect(issues.map((i) => [i.code, i.path])).toEqual([["custom", []]]);
81+
}
82+
83+
// Not specific to z.instanceof(). A base whose type mismatch aborts implicitly rather than explicitly must still not run the property checks against the rejected value.
84+
const obj = z
85+
.object({ a: z.string(), b: z.string() })
86+
.check(...z.properties({ a: z.literal("x"), b: z.literal("y") }));
87+
expect(obj.safeParse({ a: "x", b: "y" }).success).toBe(true);
88+
expect(obj.safeParse({ a: "!", b: "!" }).error!.issues.map((i) => i.path)).toEqual([["a"]]);
89+
for (const input of [null, undefined, 5]) {
90+
expect(obj.safeParse(input).error!.issues.map((i) => [i.code, i.path])).toEqual([["invalid_type", []]]);
91+
}
92+
93+
// Known looseness versus the longhand, pinned so it stays deliberate: every element is typed over the whole shape, and `$ZodCheckInternals.check()` is a method, so TypeScript compares it bivariantly and accepts a check type that is a subtype of the target. Naming a key the target lacks therefore compiles here and fails at parse time, where the equivalent chain of `z.property()` calls rejects it outright. Typing each element over only its own key fixes that and breaks every valid call, since a union argument must satisfy the target on its own.
94+
z.object({ a: z.string() }).check(...z.properties({ a: z.literal("x"), b: z.literal("y") }));
95+
expect(
96+
z
97+
.object({ a: z.string() })
98+
.check(...z.properties({ a: z.string(), b: z.literal("y") }))
99+
.safeParse({ a: "ok" })
100+
.error!.issues.map((i) => [i.code, i.path])
101+
).toEqual([["invalid_value", ["b"]]]);
102+
103+
// Plain property checks carry no `when`, so the schema stays on the compiled fast path.
104+
const compiled = z.compile(httpsUrl);
105+
expect(compiled.safeParse(new URL("https://example.com")).success).toBe(true);
106+
expect(compiled.safeParse(new URL("http://localhost")).error!.issues.map((i) => i.path)).toEqual([["protocol"]]);
107+
});

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,15 @@ export function _property<K extends string, T extends schemas.$ZodType>(
11271127
});
11281128
}
11291129

1130+
// @__NO_SIDE_EFFECTS__
1131+
export function _properties<Shape extends schemas.$ZodShape>(
1132+
shape: Shape
1133+
): checks.$ZodCheckProperty<{ -readonly [k in keyof Shape]: core.output<Shape[k]> }>[] {
1134+
return Object.entries(shape).map(
1135+
([property, schema]) => new checks.$ZodCheckProperty({ check: "property", property, schema })
1136+
) as any;
1137+
}
1138+
11301139
export type $ZodCheckMimeTypeParams = CheckParams<checks.$ZodCheckMimeType, "mime" | "when">;
11311140
// @__NO_SIDE_EFFECTS__
11321141
export function _mime(types: util.MimeTypes[], params?: string | $ZodCheckMimeTypeParams): checks.$ZodCheckMimeType {

packages/zod/src/v4/mini/checks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export {
2323
_startsWith as startsWith,
2424
_endsWith as endsWith,
2525
_property as property,
26+
_properties as properties,
2627
_mime as mime,
2728
_overwrite as overwrite,
2829
_normalize as normalize,

packages/zod/src/v4/mini/tests/checks.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ test("z.overwrite", () => {
140140
// toUpperCase;
141141
// property
142142

143+
test("z.properties", () => {
144+
const a = z.instanceof(URL).check(...z.properties({ protocol: z.literal("https:" as string), hostname: z.string() }));
145+
expect(z.safeParse(a, new URL("https://example.com")).success).toEqual(true);
146+
expect(z.safeParse(a, new URL("http://example.com")).error!.issues.map((i) => i.path)).toEqual([["protocol"]]);
147+
});
148+
143149
test("abort early", () => {
144150
const schema = z.string().check(
145151
z.refine((val) => val.length > 1),

0 commit comments

Comments
 (0)