Skip to content

Commit 413cce9

Browse files
authored
fix(v4): make z.properties() a check again (#6594)
Reverts the schema role that #6536 gave `z.properties()`. It is a check again, as in 4.5: it spreads into `.check()`, and `z.instanceof().properties()` stays as sugar over it. The standalone form is gone — `z.properties(shape).parse()`, `z.infer` of it, `z.toJSONSchema()` and `z.compile()` of it, and the cycle tracking only the schema role had. The schema form was a trap with transforms. A transform inside the shape ran and was discarded, so the parsed value and the inferred type were both the input side, and writing the result back is not an option for the objects the feature exists for: assigning to a getter-only property such as `Response.status` throws. As a check, "the value is never changed" is what a check already means. What stays: every failing key reports, symbol keys, the `params` argument, and compile support. The check now lives next to `$ZodCheckProperty` as `$ZodCheckProperties` and installs its `Symbol.iterator` as an own property, so the symbol-key loop in `util.members` and the memoizer's callable arm go with the schema role. The API reference drops the standalone paragraph; the 4.6 post already led with the method. Bundle, gzipped from the built package: mini −17 / −16 / −16 on boolean / string / object, classic −35 / −38 / −44, the named import −447. Runtime and memory: the check body is the same code moved, construction pays one `Object.defineProperty` per `z.properties()` call, and each check instance carries one extra own property. This removes an API that shipped in 4.6.0.
1 parent 75d63ee commit 413cce9

19 files changed

Lines changed: 127 additions & 407 deletions

packages/docs/content/api.mdx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2352,19 +2352,9 @@ type OkResponse = z.infer<typeof okResponse>;
23522352
// => Response & { status: number; redirected: false }
23532353
```
23542354

2355-
The underlying `z.properties()` is a standalone schema. It asserts the named properties in place and returns its input untouched — unlike `z.object()`, it never builds a new object, so prototypes, unlisted keys, and object identity survive. Schemas inside the shape are validated but their results are discarded, so a transform or a default never changes the parsed value.
2355+
The check asserts each property in place. Unlike `z.object()`, it never builds a new object, so prototypes, unlisted keys, and object identity survive. Schemas inside the shape are validated but their results are discarded, so a transform or a default never changes the parsed value.
23562356

2357-
```ts
2358-
const Named = z.properties({ name: z.string() });
2359-
2360-
class Dog {
2361-
constructor(public name: string) {}
2362-
}
2363-
const rex = new Dog("Rex");
2364-
Named.parse(rex); // => rex (the same instance)
2365-
```
2366-
2367-
Spreading into `.check()` works too: `z.instanceof(Response).check(...z.properties({ ... }))`. As a check it asserts on whatever the base schema produced, so `z.string().check(...z.properties({ length: z.number().min(3) }))` reads a string's length; as a standalone schema it expects an object.
2357+
The underlying `z.properties()` is a check, so it spreads into `.check()` too: `z.instanceof(Response).check(...z.properties({ ... }))`. It asserts on whatever the base schema produced, so `z.string().check(...z.properties({ length: z.number().min(3) }))` reads a string's length.
23682358

23692359
## Refinements
23702360

packages/treeshake/bundle-size.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ const CEILINGS: Record<string, number> = {
4242
// Also carries the Standard Schema issue bag: a failing `~standard.validate` parses with a plain issue holder instead of constructing a ZodError. Measured 2974 / 3455 / 4540 locally plus 28 headroom.
4343
// Also carries the lazy `safeParse` error: a getter and a setter on the failing result, and the async wrapper that keeps a sync throw a rejection. Measured 3021 / 3497 / 4581 locally plus 18 headroom.
4444
// Also carries the symbol-key loop in `util.members`, which is what installs `$ZodProperties`'s `Symbol.iterator` on its prototype, and the memoizer's shared reference predicate. Measured 3036 / 3516 / 4611 locally plus 18 headroom.
45-
"zod-mini-boolean": 3088,
45+
// Lowered by the measured −17 / −16 / −16 from making z.properties() a check again: the symbol-key loop in `util.members` and the memoizer's callable arm left with the schema role. Measured 3044 / 3418 / 4629 locally plus 18 headroom; the object ceiling had been overtaken to zero headroom on main, so its number moves up by two while the bundle moves down.
46+
"zod-mini-boolean": 3062,
4647
// Also carries the code-point string length scan: `.min`/`.max`/`.length` on a string pulls in the surrogate walk.
4748
// Also carries the Standard Schema issue bag: a failing `~standard.validate` parses with a plain issue holder instead of constructing a ZodError. Measured 2974 / 3455 / 4540 locally plus 28 headroom.
4849
// Also carries the lazy `safeParse` error: a getter and a setter on the failing result, and the async wrapper that keeps a sync throw a rejection. Measured 3021 / 3497 / 4581 locally plus 18 headroom.
4950
// Also carries the symbol-key loop in `util.members`, which is what installs `$ZodProperties`'s `Symbol.iterator` on its prototype, and the memoizer's shared reference predicate. Measured 3036 / 3516 / 4611 locally plus 18 headroom.
5051
// Lowered by the measured −105 when checks stopped writing metadata into the bag at attach time: the string length/format closures and the per-instance bounded regex left every string bundle, and the template literal now derives its own part patterns.
51-
"zod-mini-string": 3462,
52+
// Lowered by the measured −17 / −16 / −16 from making z.properties() a check again: the symbol-key loop in `util.members` and the memoizer's callable arm left with the schema role. Measured 3044 / 3418 / 4629 locally plus 18 headroom; the object ceiling had been overtaken to zero headroom on main, so its number moves up by two while the bundle moves down.
53+
"zod-mini-string": 3436,
5254
// Also carries the construction-time discriminator check, which writes a WeakMap entry from `$ZodObject`, so every bundle containing `z.object` pays for it whether or not it builds a discriminated union.
5355
// Also carries the declared symbol keys from #6448: `normalizeDef` collects the shape's own symbols and the parse loop walks them. Almost none of that is the `Reflect.ownKeys` conversions — reverting all eight of them measures a byte larger.
5456
// Also carries the memoizer seam from #6482: each container init reads `globalConfig.memoizer` and calls `attach`, which is what lets `zod/mini` opt into cycle support. Measured 4512 locally but 4533 on CI — the gzip stream differs across zlib builds — so the headroom rides on the CI number.
@@ -57,7 +59,8 @@ const CEILINGS: Record<string, number> = {
5759
// Also carries the symbol-key loop in `util.members`, which is what installs `$ZodProperties`'s `Symbol.iterator` on its prototype, and the memoizer's shared reference predicate. Measured 3036 / 3516 / 4611 locally plus 18 headroom.
5860
// Also carries the guards `validate` parses under: an `aborted` check in each container loop, and two early exits in the generated object parser. Only a bundle containing a container pays — boolean is byte-identical to main and string measures three bytes smaller — so this ceiling rises by the measured +76 and the other two do not move. Measured 4696 locally plus 18 headroom.
5961
// Lowered by the measured −69 for the same bag strip: the string and number inits no longer carry pattern derivation or attach-time metadata writes.
60-
"zod-mini-object": 4645,
62+
// Lowered by the measured −17 / −16 / −16 from making z.properties() a check again: the symbol-key loop in `util.members` and the memoizer's callable arm left with the schema role. Measured 3044 / 3418 / 4629 locally plus 18 headroom; the object ceiling had been overtaken to zero headroom on main, so its number moves up by two while the bundle moves down.
63+
"zod-mini-object": 4647,
6164
};
6265

6366
/**

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/schemas.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2791,28 +2791,6 @@ export const ZodCustom: core.$constructor<ZodCustom> = /*@__PURE__*/ core.$const
27912791
inst._zod.processJSONSchema = (ctx, json, params) => processors.customProcessor(inst, ctx, json, params);
27922792
});
27932793

2794-
// ZodProperties
2795-
export interface ZodProperties<Shape extends core.$ZodShape = core.$ZodShape>
2796-
extends _ZodType<core.$ZodPropertiesInternals<Shape>>,
2797-
core.$ZodProperties<Shape> {
2798-
"~standard": ZodStandardSchemaWithJSON<this>;
2799-
}
2800-
export const ZodProperties: core.$constructor<ZodProperties> = /*@__PURE__*/ core.$constructor(
2801-
"ZodProperties",
2802-
(inst, def) => {
2803-
_ensureDefaultMemoizer();
2804-
core.$ZodProperties.init(inst, def);
2805-
ZodType.init(inst, def);
2806-
}
2807-
);
2808-
2809-
export function properties<Shape extends core.$ZodShape>(
2810-
shape: Shape,
2811-
params?: string | core.$ZodPropertiesParams
2812-
): ZodProperties<Shape> {
2813-
return core._properties(ZodProperties, shape, params) as any;
2814-
}
2815-
28162794
// custom checks
28172795
export function check<O = unknown>(fn: core.CheckFn<O>): core.$ZodCheck<O> {
28182796
const ch = new core.$ZodCheck({
@@ -2860,7 +2838,7 @@ type ZodInstanceOfParams = core.Params<
28602838
export interface ZodInstanceOf<T = unknown> extends ZodCustom<T, T> {
28612839
properties<Shape extends core.$ZodShape>(
28622840
shape: Shape,
2863-
params?: string | core.$ZodPropertiesParams
2841+
params?: string | core.$ZodCheckPropertiesParams
28642842
): ZodInstanceOf<T & core.$InferObjectInput<Shape, {}>>;
28652843
}
28662844
export const ZodInstanceOf: core.$constructor<ZodInstanceOf> = /*@__PURE__*/ core.$constructor(
@@ -2869,9 +2847,9 @@ export const ZodInstanceOf: core.$constructor<ZodInstanceOf> = /*@__PURE__*/ cor
28692847
ZodCustom.init(inst, def);
28702848
},
28712849
{
2872-
properties(shape: core.$ZodShape, params?: string | core.$ZodPropertiesParams) {
2850+
properties(shape: core.$ZodShape, params?: string | core.$ZodCheckPropertiesParams) {
28732851
// asserts in place, so the narrowed output type is truthful without a wrapper
2874-
return this.check(properties(shape, params)) as any;
2852+
return this.check(core._properties(shape, params)) as any;
28752853
},
28762854
}
28772855
);

packages/zod/src/v4/classic/tests/cyclic-data.test.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -924,17 +924,8 @@ test("a deferred edge stops being assumed a cycle once the graph resolves", () =
924924
expect(z.core.isRecursiveSchema(Cyclic)).toBe(true);
925925
});
926926

927-
// `z.object` resolves its shape by spread, so a non-enumerable key is no part of it; `z.properties` reads every own key, so one there is still parsed and can still close a cycle
928-
test("a non-enumerable key counts for z.properties and not for z.object", () => {
929-
const props = (enumerable: boolean) => {
930-
const shape: Record<string, any> = { id: z.number() };
931-
const P = z.properties(shape);
932-
Object.defineProperty(shape, "next", { value: z.lazy(() => z.optional(P)), enumerable, configurable: true });
933-
return P;
934-
};
935-
expect(z.core.isRecursiveSchema(props(true) as any)).toBe(true);
936-
expect(z.core.isRecursiveSchema(props(false) as any)).toBe(true);
937-
927+
// `z.object` resolves its shape by spread, so a non-enumerable key is no part of it
928+
test("a non-enumerable key does not count for z.object", () => {
938929
const object = (enumerable: boolean) => {
939930
const shape: Record<string, any> = { id: z.number() };
940931
const O: any = z.object(shape);

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ test("first party switch", () => {
8080
break;
8181
case "file":
8282
break;
83-
case "properties":
84-
break;
8583
case "lazy":
8684
break;
8785
case "function":
@@ -172,8 +170,6 @@ test("$ZodSchemaTypes", () => {
172170
break;
173171
case "lazy":
174172
break;
175-
case "properties":
176-
break;
177173
case "function":
178174
break;
179175

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ test("z.properties", () => {
9090
expect(obj.safeParse(input).error!.issues.map((i) => [i.code, i.path])).toEqual([["invalid_type", []]]);
9191
}
9292

93-
// Known looseness versus the longhand, pinned so it stays deliberate: the schema-as-check 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.
93+
// Known looseness versus the longhand, pinned so it stays deliberate: the check 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.
9494
z.object({ a: z.string() }).check(...z.properties({ a: z.literal("x"), b: z.literal("y") }));
9595
expect(
9696
z

0 commit comments

Comments
 (0)