From 21af3ef475b46665f0ab339fcc20cbbd46eba13d Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 28 Apr 2026 20:18:23 -0700 Subject: [PATCH 1/5] perf(v4): lazy-bind builder methods to shared internal prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder methods (.optional, .nullable, .array, .pick, etc.) were allocated as fresh per-instance closures during construction. On a plain z.string() that's ~50 closures up front, dominated by JSFunction + Context bytes, even when the user never calls them. Move builders onto a hidden prototype layer ($internalProto) inserted between _.prototype (user-extension space) and the parent. On first access from any instance, defineLazy allocates fn.bind(this) and caches it as an own enumerable property; subsequent accesses skip the getter. Detached usage (const m = schema.optional; m()) keeps working because m is a bound function with this already resolved. Parse-family methods (parse, safeParse, encode, etc.) intentionally stay as eager per-instance closures — they're the hot path AND the most-detached methods (arr.map(schema.parse)), so paying the allocation up front is worth the monomorphic call site. Adds a regression test guarding detached method behavior. --- packages/zod/src/v4/classic/schemas.ts | 495 +++++++++++++----- .../v4/classic/tests/detached-methods.test.ts | 197 +++++++ packages/zod/src/v4/core/core.ts | 64 +++ 3 files changed, 629 insertions(+), 127 deletions(-) create mode 100644 packages/zod/src/v4/classic/tests/detached-methods.test.ts diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index f4b6aa983d..5f914d593d 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -8,6 +8,278 @@ import * as checks from "./checks.js"; import * as iso from "./iso.js"; import * as parse from "./parse.js"; +// Tracks which (internalProto, key) pairs already have lazy methods installed, +// so that we set up each prototype exactly once instead of once per instance. +const _initSet = new WeakMap>(); + +function _installLazyMethods( + inst: object, + methodGroupKey: string, + methods: Record any> +): void { + const proto = (inst as any)._zod?.constr?.[core.$internalProto] ?? Object.getPrototypeOf(inst); + let installed = _initSet.get(proto); + if (!installed) { + installed = new Set(); + _initSet.set(proto, installed); + } + if (installed.has(methodGroupKey)) return; + installed.add(methodGroupKey); + for (const key in methods) { + core.defineLazy(proto, key, methods[key]!); + } +} + +// Shared method bodies — use `this` (resolved per-instance via the lazy bind +// in `defineLazy`). All return casts are `as any` because TS cannot prove +// shared `this` matches the polymorphic `this` in the interface; declared +// types remain authoritative. +function _sharedCheck(this: ZodType, ...chks: (core.CheckFn | core.$ZodCheck)[]) { + const def = this.def as core.$ZodTypeDef & { checks?: core.$ZodCheck[] }; + return this.clone( + util.mergeDefs(def, { + checks: [ + ...(def.checks ?? []), + ...chks.map((ch) => + typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch + ), + ], + }), + { parent: true } + ) as any; +} +function _sharedClone(this: ZodType, def?: any, params?: { parent: boolean }) { + return core.clone(this as any, def, params) as any; +} +function _sharedBrand(this: ZodType) { + return this as any; +} +function _sharedRegister(this: ZodType, reg: any, meta: any) { + reg.add(this, meta); + return this as any; +} +function _sharedRefine(this: ZodType, check: any, params?: any) { + return this.check(refine(check, params)) as any; +} +function _sharedSuperRefine(this: ZodType, refinement: any, params?: any) { + return this.check(superRefine(refinement, params)) as any; +} +function _sharedOverwrite(this: ZodType, fn: (x: any) => any) { + return this.check(checks.overwrite(fn)) as any; +} +function _sharedOptional(this: ZodType) { + return optional(this as any) as any; +} +function _sharedExactOptional(this: ZodType) { + return exactOptional(this as any) as any; +} +function _sharedNullable(this: ZodType) { + return nullable(this as any) as any; +} +function _sharedNullish(this: ZodType) { + return optional(nullable(this as any)) as any; +} +function _sharedNonoptional(this: ZodType, params?: any) { + return nonoptional(this as any, params) as any; +} +function _sharedArray(this: ZodType) { + return array(this as any) as any; +} +function _sharedOr(this: ZodType, arg: any) { + return union([this, arg] as any) as any; +} +function _sharedAnd(this: ZodType, arg: any) { + return intersection(this as any, arg) as any; +} +function _sharedTransform(this: ZodType, tx: any) { + return pipe(this as any, transform(tx as any)) as any; +} +function _sharedDefault(this: ZodType, d: any) { + return _default(this as any, d) as any; +} +function _sharedPrefault(this: ZodType, d: any) { + return prefault(this as any, d) as any; +} +function _sharedCatch(this: ZodType, params: any) { + return _catch(this as any, params) as any; +} +function _sharedPipe(this: ZodType, target: any) { + return pipe(this as any, target) as any; +} +function _sharedReadonly(this: ZodType) { + return readonly(this as any) as any; +} +function _sharedDescribe(this: ZodType, description: string) { + const cl = this.clone(); + core.globalRegistry.add(cl, { description }); + return cl as any; +} +function _sharedMeta(this: ZodType, ...args: any[]) { + if (args.length === 0) return core.globalRegistry.get(this as any); + const cl = this.clone(); + core.globalRegistry.add(cl, args[0]); + return cl as any; +} +function _sharedIsOptional(this: ZodType) { + return this.safeParse(undefined).success; +} +function _sharedIsNullable(this: ZodType) { + return this.safeParse(null).success; +} +function _sharedApply(this: ZodType, fn: (schema: any) => any) { + return fn(this); +} + +// _ZodString — string validation/transform shared methods +function _sharedRegex(this: any, ...args: any[]) { + return this.check((checks.regex as any)(...args)); +} +function _sharedIncludes(this: any, ...args: any[]) { + return this.check((checks.includes as any)(...args)); +} +function _sharedStartsWith(this: any, ...args: any[]) { + return this.check((checks.startsWith as any)(...args)); +} +function _sharedEndsWith(this: any, ...args: any[]) { + return this.check((checks.endsWith as any)(...args)); +} +function _sharedStrMin(this: any, ...args: any[]) { + return this.check((checks.minLength as any)(...args)); +} +function _sharedStrMax(this: any, ...args: any[]) { + return this.check((checks.maxLength as any)(...args)); +} +function _sharedStrLength(this: any, ...args: any[]) { + return this.check((checks.length as any)(...args)); +} +function _sharedStrNonempty(this: any, ...args: any[]) { + return this.check((checks.minLength as any)(1, ...args)); +} +function _sharedLowercase(this: any, params?: any) { + return this.check(checks.lowercase(params)); +} +function _sharedUppercase(this: any, params?: any) { + return this.check(checks.uppercase(params)); +} +function _sharedTrim(this: any) { + return this.check(checks.trim()); +} +function _sharedNormalize(this: any, ...args: any[]) { + return this.check(checks.normalize(...args)); +} +function _sharedToLowerCase(this: any) { + return this.check(checks.toLowerCase()); +} +function _sharedToUpperCase(this: any) { + return this.check(checks.toUpperCase()); +} +function _sharedSlugify(this: any) { + return this.check(checks.slugify()); +} + +// ZodNumber +function _sharedNumGt(this: any, value: number, params?: any) { + return this.check(checks.gt(value, params)); +} +function _sharedNumGte(this: any, value: number, params?: any) { + return this.check(checks.gte(value, params)); +} +function _sharedNumLt(this: any, value: number, params?: any) { + return this.check(checks.lt(value, params)); +} +function _sharedNumLte(this: any, value: number, params?: any) { + return this.check(checks.lte(value, params)); +} +function _sharedNumInt(this: any, params?: any) { + return this.check(int(params)); +} +function _sharedNumPositive(this: any, params?: any) { + return this.check(checks.gt(0, params)); +} +function _sharedNumNonnegative(this: any, params?: any) { + return this.check(checks.gte(0, params)); +} +function _sharedNumNegative(this: any, params?: any) { + return this.check(checks.lt(0, params)); +} +function _sharedNumNonpositive(this: any, params?: any) { + return this.check(checks.lte(0, params)); +} +function _sharedNumMultipleOf(this: any, value: number, params?: any) { + return this.check(checks.multipleOf(value, params)); +} +function _sharedNumFinite(this: any) { + return this; +} + +// ZodArray +function _sharedArrMin(this: any, n: number, params?: any) { + return this.check(checks.minLength(n, params)); +} +function _sharedArrMax(this: any, n: number, params?: any) { + return this.check(checks.maxLength(n, params)); +} +function _sharedArrLength(this: any, n: number, params?: any) { + return this.check(checks.length(n, params)); +} +function _sharedArrNonempty(this: any, params?: any) { + return this.check(checks.minLength(1, params)); +} +function _sharedArrUnwrap(this: any) { + return this.element; +} + +// ZodObject +function _sharedObjKeyof(this: any) { + return _enum(Object.keys(this._zod.def.shape)); +} +function _sharedObjCatchall(this: any, catchall: any) { + return this.clone({ ...this._zod.def, catchall }); +} +function _sharedObjPassthrough(this: any) { + return this.clone({ ...this._zod.def, catchall: unknown() }); +} +function _sharedObjLoose(this: any) { + return this.clone({ ...this._zod.def, catchall: unknown() }); +} +function _sharedObjStrict(this: any) { + return this.clone({ ...this._zod.def, catchall: never() }); +} +function _sharedObjStrip(this: any) { + return this.clone({ ...this._zod.def, catchall: undefined }); +} +function _sharedObjExtend(this: any, incoming: any) { + return util.extend(this, incoming); +} +function _sharedObjSafeExtend(this: any, incoming: any) { + return util.safeExtend(this, incoming); +} +function _sharedObjMerge(this: any, other: any) { + return util.merge(this, other); +} +function _sharedObjPick(this: any, mask: any) { + return util.pick(this, mask); +} +function _sharedObjOmit(this: any, mask: any) { + return util.omit(this, mask); +} +function _sharedObjPartial(this: any, ...args: any[]) { + return util.partial(ZodOptional, this, args[0]); +} +function _sharedObjRequired(this: any, ...args: any[]) { + return util.required(ZodNonOptional, this, args[0]); +} + +// Wrappers — shared unwrap (placeholder; will use when extending to wrapper types) +// @ts-ignore +function _sharedUnwrap(this: any) { + return this._zod.def.innerType; +} +// @ts-ignore +function _sharedLazyUnwrap(this: any) { + return this._zod.def.getter(); +} + /////////////////////////////////////////// /////////////////////////////////////////// //////////// //////////// @@ -168,38 +440,16 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct inst.type = def.type; Object.defineProperty(inst, "_def", { value: def }); - // base methods - inst.check = (...checks) => { - return inst.clone( - util.mergeDefs(def, { - checks: [ - ...(def.checks ?? []), - ...checks.map((ch) => - typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch - ), - ], - }), - { - parent: true, - } - ); - }; - inst.with = inst.check; - inst.clone = (def, params) => core.clone(inst, def, params); - inst.brand = () => inst as any; - inst.register = ((reg: any, meta: any) => { - reg.add(inst, meta); - return inst; - }) as any; - - // parsing + // Parse-family is intentionally kept as per-instance closures: these are + // the hot path AND the most-detached methods (`arr.map(schema.parse)`, + // `const { parse } = schema`, etc.). Eager closures here mean callers pay + // ~12 closure allocations per schema but get monomorphic call sites and + // detached usage that "just works". inst.parse = (data, params) => parse.parse(inst, data, params, { callee: inst.parse }); inst.safeParse = (data, params) => parse.safeParse(inst, data, params); inst.parseAsync = async (data, params) => parse.parseAsync(inst, data, params, { callee: inst.parseAsync }); inst.safeParseAsync = async (data, params) => parse.safeParseAsync(inst, data, params); inst.spa = inst.safeParseAsync; - - // encoding/decoding inst.encode = (data, params) => parse.encode(inst, data, params); inst.decode = (data, params) => parse.decode(inst, data, params); inst.encodeAsync = async (data, params) => parse.encodeAsync(inst, data, params); @@ -209,53 +459,47 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct inst.safeEncodeAsync = async (data, params) => parse.safeEncodeAsync(inst, data, params); inst.safeDecodeAsync = async (data, params) => parse.safeDecodeAsync(inst, data, params); - // refinements - inst.refine = (check, params) => inst.check(refine(check, params)) as never; - inst.superRefine = (refinement, params) => inst.check(superRefine(refinement, params)); - inst.overwrite = (fn) => inst.check(checks.overwrite(fn)); - - // wrappers - inst.optional = () => optional(inst); - inst.exactOptional = () => exactOptional(inst); - inst.nullable = () => nullable(inst); - inst.nullish = () => optional(nullable(inst)); - inst.nonoptional = (params) => nonoptional(inst, params); - inst.array = () => array(inst); - inst.or = (arg) => union([inst, arg]); - inst.and = (arg) => intersection(inst, arg); - inst.transform = (tx) => pipe(inst, transform(tx as any)) as never; - inst.default = (def) => _default(inst, def); - inst.prefault = (def) => prefault(inst, def); - // inst.coalesce = (def, params) => coalesce(inst, def, params); - inst.catch = (params) => _catch(inst, params); - inst.pipe = (target) => pipe(inst, target); - inst.readonly = () => readonly(inst); - - // meta - inst.describe = (description) => { - const cl = inst.clone(); - core.globalRegistry.add(cl, { description }); - return cl; - }; + // All builder methods are placed on the internal prototype as lazy-bind + // getters. On first access per-instance, a bound thunk is allocated and + // cached as an own property; subsequent accesses skip the getter. This + // means: no per-instance allocation for unused methods, full + // detachability preserved (`const m = schema.optional; m()` works), and + // shared underlying function references across all instances. + _installLazyMethods(inst, "ZodType", { + check: _sharedCheck, + with: _sharedCheck, + clone: _sharedClone, + brand: _sharedBrand, + register: _sharedRegister, + refine: _sharedRefine, + superRefine: _sharedSuperRefine, + overwrite: _sharedOverwrite, + optional: _sharedOptional, + exactOptional: _sharedExactOptional, + nullable: _sharedNullable, + nullish: _sharedNullish, + nonoptional: _sharedNonoptional, + array: _sharedArray, + or: _sharedOr, + and: _sharedAnd, + transform: _sharedTransform, + default: _sharedDefault, + prefault: _sharedPrefault, + catch: _sharedCatch, + pipe: _sharedPipe, + readonly: _sharedReadonly, + describe: _sharedDescribe, + meta: _sharedMeta, + isOptional: _sharedIsOptional, + isNullable: _sharedIsNullable, + apply: _sharedApply, + }); Object.defineProperty(inst, "description", { get() { return core.globalRegistry.get(inst)?.description; }, configurable: true, }); - inst.meta = (...args: any) => { - if (args.length === 0) { - return core.globalRegistry.get(inst); - } - const cl = inst.clone(); - core.globalRegistry.add(cl, args[0]); - return cl as any; - }; - - // helpers - inst.isOptional = () => inst.safeParse(undefined).success; - inst.isNullable = () => inst.safeParse(null).success; - inst.apply = (fn) => fn(inst); return inst; }); @@ -298,24 +542,23 @@ export const _ZodString: core.$constructor<_ZodString> = /*@__PURE__*/ core.$con inst.minLength = bag.minimum ?? null; inst.maxLength = bag.maximum ?? null; - // validations - inst.regex = (...args) => inst.check(checks.regex(...args)); - inst.includes = (...args) => inst.check(checks.includes(...args)); - inst.startsWith = (...args) => inst.check(checks.startsWith(...args)); - inst.endsWith = (...args) => inst.check(checks.endsWith(...args)); - inst.min = (...args) => inst.check(checks.minLength(...args)); - inst.max = (...args) => inst.check(checks.maxLength(...args)); - inst.length = (...args) => inst.check(checks.length(...args)); - inst.nonempty = (...args) => inst.check(checks.minLength(1, ...args)); - inst.lowercase = (params) => inst.check(checks.lowercase(params)); - inst.uppercase = (params) => inst.check(checks.uppercase(params)); - - // transforms - inst.trim = () => inst.check(checks.trim()); - inst.normalize = (...args) => inst.check(checks.normalize(...args)); - inst.toLowerCase = () => inst.check(checks.toLowerCase()); - inst.toUpperCase = () => inst.check(checks.toUpperCase()); - inst.slugify = () => inst.check(checks.slugify()); + _installLazyMethods(inst, "_ZodString", { + regex: _sharedRegex, + includes: _sharedIncludes, + startsWith: _sharedStartsWith, + endsWith: _sharedEndsWith, + min: _sharedStrMin, + max: _sharedStrMax, + length: _sharedStrLength, + nonempty: _sharedStrNonempty, + lowercase: _sharedLowercase, + uppercase: _sharedUppercase, + trim: _sharedTrim, + normalize: _sharedNormalize, + toLowerCase: _sharedToLowerCase, + toUpperCase: _sharedToUpperCase, + slugify: _sharedSlugify, + }); }); export interface ZodString extends _ZodString> { @@ -867,23 +1110,23 @@ export const ZodNumber: core.$constructor = /*@__PURE__*/ core.$const inst._zod.processJSONSchema = (ctx, json, params) => processors.numberProcessor(inst, ctx, json, params); - inst.gt = (value, params) => inst.check(checks.gt(value, params)); - inst.gte = (value, params) => inst.check(checks.gte(value, params)); - inst.min = (value, params) => inst.check(checks.gte(value, params)); - inst.lt = (value, params) => inst.check(checks.lt(value, params)); - inst.lte = (value, params) => inst.check(checks.lte(value, params)); - inst.max = (value, params) => inst.check(checks.lte(value, params)); - inst.int = (params) => inst.check(int(params)); - inst.safe = (params) => inst.check(int(params)); - inst.positive = (params) => inst.check(checks.gt(0, params)); - inst.nonnegative = (params) => inst.check(checks.gte(0, params)); - inst.negative = (params) => inst.check(checks.lt(0, params)); - inst.nonpositive = (params) => inst.check(checks.lte(0, params)); - inst.multipleOf = (value, params) => inst.check(checks.multipleOf(value, params)); - inst.step = (value, params) => inst.check(checks.multipleOf(value, params)); - - // inst.finite = (params) => inst.check(core.finite(params)); - inst.finite = () => inst; + _installLazyMethods(inst, "ZodNumber", { + gt: _sharedNumGt, + gte: _sharedNumGte, + min: _sharedNumGte, + lt: _sharedNumLt, + lte: _sharedNumLte, + max: _sharedNumLte, + int: _sharedNumInt, + safe: _sharedNumInt, + positive: _sharedNumPositive, + nonnegative: _sharedNumNonnegative, + negative: _sharedNumNegative, + nonpositive: _sharedNumNonpositive, + multipleOf: _sharedNumMultipleOf, + step: _sharedNumMultipleOf, + finite: _sharedNumFinite, + }); const bag = inst._zod.bag; inst.minValue = @@ -1166,12 +1409,13 @@ export const ZodArray: core.$constructor = /*@__PURE__*/ core.$constru inst._zod.processJSONSchema = (ctx, json, params) => processors.arrayProcessor(inst, ctx, json, params); inst.element = def.element; - inst.min = (minLength, params) => inst.check(checks.minLength(minLength, params)); - inst.nonempty = (params) => inst.check(checks.minLength(1, params)); - inst.max = (maxLength, params) => inst.check(checks.maxLength(maxLength, params)); - inst.length = (len, params) => inst.check(checks.length(len, params)); - - inst.unwrap = () => inst.element; + _installLazyMethods(inst, "ZodArray", { + min: _sharedArrMin, + nonempty: _sharedArrNonempty, + max: _sharedArrMax, + length: _sharedArrLength, + unwrap: _sharedArrUnwrap, + }); }); export function array(element: T, params?: string | core.$ZodArrayParams): ZodArray { @@ -1285,24 +1529,21 @@ export const ZodObject: core.$constructor = /*@__PURE__*/ core.$const return def.shape; }); - inst.keyof = () => _enum(Object.keys(inst._zod.def.shape)) as any; - inst.catchall = (catchall) => inst.clone({ ...inst._zod.def, catchall: catchall as any as core.$ZodType }) as any; - inst.passthrough = () => inst.clone({ ...inst._zod.def, catchall: unknown() }); - inst.loose = () => inst.clone({ ...inst._zod.def, catchall: unknown() }); - inst.strict = () => inst.clone({ ...inst._zod.def, catchall: never() }); - inst.strip = () => inst.clone({ ...inst._zod.def, catchall: undefined }); - - inst.extend = (incoming: any) => { - return util.extend(inst, incoming); - }; - inst.safeExtend = (incoming: any) => { - return util.safeExtend(inst, incoming); - }; - inst.merge = (other) => util.merge(inst, other); - inst.pick = (mask) => util.pick(inst, mask); - inst.omit = (mask) => util.omit(inst, mask); - inst.partial = (...args: any[]) => util.partial(ZodOptional, inst, args[0] as object); - inst.required = (...args: any[]) => util.required(ZodNonOptional, inst, args[0] as object); + _installLazyMethods(inst, "ZodObject", { + keyof: _sharedObjKeyof, + catchall: _sharedObjCatchall, + passthrough: _sharedObjPassthrough, + loose: _sharedObjLoose, + strict: _sharedObjStrict, + strip: _sharedObjStrip, + extend: _sharedObjExtend, + safeExtend: _sharedObjSafeExtend, + merge: _sharedObjMerge, + pick: _sharedObjPick, + omit: _sharedObjOmit, + partial: _sharedObjPartial, + required: _sharedObjRequired, + }); }); export function object>>( diff --git a/packages/zod/src/v4/classic/tests/detached-methods.test.ts b/packages/zod/src/v4/classic/tests/detached-methods.test.ts new file mode 100644 index 0000000000..9d41b2c9d9 --- /dev/null +++ b/packages/zod/src/v4/classic/tests/detached-methods.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "vitest"; +import * as z from "zod/v4"; + +/** + * Schema methods are exposed in a way that works when detached from the + * schema instance — `const opt = schema.optional; opt()` must produce a + * working `ZodOptional`, not a corrupt one. This pattern is used in real + * code (e.g. `arr.map(schema.parse)`, `arr.map(schema.optional)`, + * destructuring inside utility functions). + * + * This test caught a regression in colinhacks/zod#5870 where a memory + * optimization moved methods to the prototype and made `this`-binding + * required, silently breaking any detached usage. + */ + +const probeArgs: Record = { + // ZodType + optional: [], + exactOptional: [], + nullable: [], + nullish: [], + array: [], + describe: ["x"], + brand: [], + readonly: [], + default: ["fallback"], + catch: ["fallback"], + // _ZodString + min: [1], + max: [10], + length: [5], + nonempty: [], + trim: [], + toLowerCase: [], + toUpperCase: [], + // ZodString format methods + email: [], + url: [], + uuid: [], + cuid: [], + cuid2: [], + ulid: [], + base64: [], + base64url: [], + ipv4: [], + ipv6: [], + // ZodNumber + int: [], + positive: [], + negative: [], + finite: [], +}; + +test("detached parse-family methods work without `this` binding", () => { + const schema = z.string(); + const { parse, safeParse } = schema; + + expect(parse("hello")).toBe("hello"); + expect(safeParse("hello").success).toBe(true); +}); + +test("detached schema.optional() returns a working ZodOptional", () => { + const schema = z.string(); + const opt = schema.optional; + + const detached = opt(); + + expect(detached).toBeInstanceOf(z.ZodOptional); + expect(detached.safeParse("hello").success).toBe(true); + expect(detached.safeParse(undefined).success).toBe(true); + expect(detached.safeParse(123).success).toBe(false); +}); + +test("detached schema.nullable() returns a working ZodNullable", () => { + const schema = z.string(); + const nul = schema.nullable; + + const detached = nul(); + + expect(detached).toBeInstanceOf(z.ZodNullable); + expect(detached.safeParse("hello").success).toBe(true); + expect(detached.safeParse(null).success).toBe(true); + expect(detached.safeParse(123).success).toBe(false); +}); + +test("detached schema.array() returns a working ZodArray", () => { + const schema = z.string(); + const arr = schema.array; + + const detached = arr(); + + expect(detached).toBeInstanceOf(z.ZodArray); + expect(detached.safeParse(["a", "b"]).success).toBe(true); + expect(detached.safeParse([1, 2]).success).toBe(false); +}); + +test("detached schema.describe() returns a described schema", () => { + const schema = z.string(); + const describe = schema.describe; + + const described = describe("hello world"); + + expect(described.description).toBe("hello world"); +}); + +test("detached refinement still validates", () => { + const schema = z.string(); + const refine = schema.refine; + + const refined = refine((s: string) => s.startsWith("x"), "must start with x"); + + expect(refined.safeParse("xhello").success).toBe(true); + expect(refined.safeParse("hello").success).toBe(false); +}); + +test("detached chained calls work — schema.optional then parse", () => { + const schema = z.string(); + const opt = schema.optional; + const optionalSchema = opt(); + const { parse } = optionalSchema; + + expect(parse("hi")).toBe("hi"); + expect(parse(undefined)).toBe(undefined); +}); + +test("detached parse can be called as a free function", () => { + const schema = z.string(); + const parse = schema.parse; + const inputs = ["a", "b", "c"]; + + const results = inputs.map((v) => parse(v)); + + expect(results).toEqual(["a", "b", "c"]); +}); + +test("detached methods on z.number() work", () => { + const schema = z.number(); + + const min = schema.min; + const max = schema.max; + const positive = schema.positive; + + expect(min(5).safeParse(3).success).toBe(false); + expect(max(5).safeParse(7).success).toBe(false); + expect(positive().safeParse(-1).success).toBe(false); +}); + +test("detached object methods work", () => { + const schema = z.object({ a: z.string(), b: z.number() }); + + const pick = schema.pick; + const omit = schema.omit; + const partial = schema.partial; + const extend = schema.extend; + + expect(Object.keys(pick({ a: true })._zod.def.shape)).toEqual(["a"]); + expect(Object.keys(omit({ a: true })._zod.def.shape)).toEqual(["b"]); + expect(partial().safeParse({}).success).toBe(true); + const extended = extend({ c: z.boolean() }); + expect(Object.keys(extended._zod.def.shape).sort()).toEqual(["a", "b", "c"]); +}); + +// Sweep across many builder methods at once. If any of them break with the +// `const m = schema.foo; m(...)` pattern, this test will report which. +test("broad sweep: detaching builder methods does not throw or produce a corrupt schema", () => { + const stringSchema = z.string(); + const numberSchema = z.number(); + + const broken: Array<{ method: string; reason: string }> = []; + + for (const [methodName, args] of Object.entries(probeArgs)) { + const target: any = methodName in stringSchema ? stringSchema : methodName in numberSchema ? numberSchema : null; + if (!target) continue; + + const detached = target[methodName] as Function | undefined; + if (typeof detached !== "function") continue; + + try { + const result = detached(...args); + // If the detached call returned a schema, sanity-check it parses + // its base type. (e.g. `optional()` should accept its inner type.) + if (result && typeof result === "object" && "_zod" in result && typeof (result as any).safeParse === "function") { + const probeValue = target === stringSchema ? "x" : 1; + const r = (result as any).safeParse(probeValue); + // success or a clean failure are both fine — we only fail on throw or + // on a schema with corrupt internal state (innerType undefined etc). + if (r === undefined || (typeof r === "object" && !("success" in r))) { + broken.push({ method: methodName, reason: "safeParse returned malformed result" }); + } + } + } catch (err: any) { + broken.push({ method: methodName, reason: err?.message ?? String(err) }); + } + } + + expect(broken).toEqual([]); +}); diff --git a/packages/zod/src/v4/core/core.ts b/packages/zod/src/v4/core/core.ts index c191fdbe6f..d917c36e55 100644 --- a/packages/zod/src/v4/core/core.ts +++ b/packages/zod/src/v4/core/core.ts @@ -9,6 +9,57 @@ export interface $constructor { init(inst: T, def: D): asserts inst is T; } +/** + * Symbol used to access the internal prototype of a Zod constructor. + * + * Each constructor created by `$constructor` maintains two prototype layers: + * inst → _.prototype (user-visible; copy-loop targets this) + * └── internalProto (library methods set by _initLazy go here) + * + * Library methods placed on internalProto are inherited (not own properties) + * which keeps instances under V8's fast-property threshold. + */ +export const $internalProto: unique symbol = Symbol("zod.internalProto"); + +/** + * Define a method on `proto` such that the FIRST time it is accessed on an + * instance, a `bind`-equivalent thunk is allocated and cached as an + * enumerable own property on that instance. Subsequent accesses skip the + * getter entirely. + * + * This preserves detached usage (`const m = schema.optional; m()` works + * because `m` is a bound function with `this` pre-resolved) while only + * allocating closures for methods actually accessed. + */ +export function defineLazy(proto: object, key: string | symbol, sharedFn: (...args: any[]) => any): void { + Object.defineProperty(proto, key, { + configurable: true, + enumerable: false, + get(this: any) { + // `bind` is roughly equivalent in cost to allocating a closure that + // captures `this`. We cache the result on the instance so this getter + // fires at most once per (instance, method). + const bound = sharedFn.bind(this); + Object.defineProperty(this, key, { + configurable: true, + writable: true, + enumerable: true, + value: bound, + }); + return bound; + }, + set(this: any, v: unknown) { + // Allow user reassignment / extension of the method on a specific instance. + Object.defineProperty(this, key, { + configurable: true, + writable: true, + enumerable: true, + value: v, + }); + }, + }); +} + /** A special constant with type `never` */ export const NEVER: never = /*@__PURE__*/ Object.freeze({ status: "aborted", @@ -55,6 +106,13 @@ export /*@__NO_SIDE_EFFECTS__*/ function $constructor { @@ -73,6 +132,11 @@ export /*@__NO_SIDE_EFFECTS__*/ function $constructor Date: Tue, 28 Apr 2026 21:16:02 -0700 Subject: [PATCH 2/5] refactor(v4): collapse lazy-bind to a classic-only helper Two simplifications on top of the previous commit, no behavior change: 1. Remove all changes from `core/core.ts`. The `$internalProto` symbol and `defineLazy` helper are gone. Classic now creates the library-owned prototype layer lazily on first install per constructor, via `Object.setPrototypeOf(constr.prototype, layer)` cached in a `WeakMap`. Net diff to `core/core.ts` is zero. 2. Drop all ~50 `_sharedXxx` named functions in favor of inline method-shorthand bodies inside each `_installLazyMethods({...})` call site. The `_shared` prefix is gone; the only underscore- prefixed helper left in classic is `_installLazyMethods` itself. Bundle size impact (esbuild --bundle --minify --format=esm, gzipped): Classic minimal (z.string().parse): 15,677 -> 15,889 (+212 B, +1.4%) Classic rich (object schema): 19,100 -> 19,355 (+255 B, +1.3%) Mini minimal (z.string()): 2,707 -> 2,705 (-2 B, identical) Mini rich (object schema): 5,367 -> 5,365 (-2 B, identical) Mini bundles are now byte-for-byte identical to main; classic overhead is roughly halved versus the previous design (~430 B -> ~212 B gz). All 3,743 tests still pass; heap/construction numbers unchanged. --- packages/zod/src/v4/classic/schemas.ts | 629 ++++++++++++------------- packages/zod/src/v4/core/core.ts | 64 --- 2 files changed, 293 insertions(+), 400 deletions(-) diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index 5f914d593d..571fed0d7f 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -8,278 +8,68 @@ import * as checks from "./checks.js"; import * as iso from "./iso.js"; import * as parse from "./parse.js"; -// Tracks which (internalProto, key) pairs already have lazy methods installed, -// so that we set up each prototype exactly once instead of once per instance. -const _initSet = new WeakMap>(); +// Lazy-bind layer for builder methods. +// +// Builder methods (`.optional`, `.array`, `.refine`, ...) live as +// non-enumerable getters on a library-owned prototype layer inserted +// between `constr.prototype` (where user extensions live) and the +// parent prototype. On first access from any instance the getter +// allocates `fn.bind(this)` and caches it as an own property on the +// instance — so detached usage (`const m = schema.optional; m()`) +// still works, and the per-instance allocation only happens for +// methods actually touched. +// +// One layer per concrete constructor; one install per (layer, group). +const _layerCache = /* @__PURE__ */ new WeakMap(); +const _installedGroups = /* @__PURE__ */ new WeakMap>(); function _installLazyMethods( inst: object, - methodGroupKey: string, - methods: Record any> + group: string, + methods: Record any> ): void { - const proto = (inst as any)._zod?.constr?.[core.$internalProto] ?? Object.getPrototypeOf(inst); - let installed = _initSet.get(proto); + const constr = (inst as any)._zod.constr; + let layer = _layerCache.get(constr); + if (!layer) { + const userProto = constr.prototype; + layer = Object.create(Object.getPrototypeOf(userProto)); + Object.setPrototypeOf(userProto, layer); + _layerCache.set(constr, layer); + } + let installed = _installedGroups.get(layer); if (!installed) { installed = new Set(); - _initSet.set(proto, installed); + _installedGroups.set(layer, installed); } - if (installed.has(methodGroupKey)) return; - installed.add(methodGroupKey); + if (installed.has(group)) return; + installed.add(group); for (const key in methods) { - core.defineLazy(proto, key, methods[key]!); + const fn = methods[key]!; + Object.defineProperty(layer, key, { + configurable: true, + enumerable: false, + get(this: any) { + const bound = fn.bind(this); + Object.defineProperty(this, key, { + configurable: true, + writable: true, + enumerable: true, + value: bound, + }); + return bound; + }, + set(this: any, v: unknown) { + Object.defineProperty(this, key, { + configurable: true, + writable: true, + enumerable: true, + value: v, + }); + }, + }); } } -// Shared method bodies — use `this` (resolved per-instance via the lazy bind -// in `defineLazy`). All return casts are `as any` because TS cannot prove -// shared `this` matches the polymorphic `this` in the interface; declared -// types remain authoritative. -function _sharedCheck(this: ZodType, ...chks: (core.CheckFn | core.$ZodCheck)[]) { - const def = this.def as core.$ZodTypeDef & { checks?: core.$ZodCheck[] }; - return this.clone( - util.mergeDefs(def, { - checks: [ - ...(def.checks ?? []), - ...chks.map((ch) => - typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch - ), - ], - }), - { parent: true } - ) as any; -} -function _sharedClone(this: ZodType, def?: any, params?: { parent: boolean }) { - return core.clone(this as any, def, params) as any; -} -function _sharedBrand(this: ZodType) { - return this as any; -} -function _sharedRegister(this: ZodType, reg: any, meta: any) { - reg.add(this, meta); - return this as any; -} -function _sharedRefine(this: ZodType, check: any, params?: any) { - return this.check(refine(check, params)) as any; -} -function _sharedSuperRefine(this: ZodType, refinement: any, params?: any) { - return this.check(superRefine(refinement, params)) as any; -} -function _sharedOverwrite(this: ZodType, fn: (x: any) => any) { - return this.check(checks.overwrite(fn)) as any; -} -function _sharedOptional(this: ZodType) { - return optional(this as any) as any; -} -function _sharedExactOptional(this: ZodType) { - return exactOptional(this as any) as any; -} -function _sharedNullable(this: ZodType) { - return nullable(this as any) as any; -} -function _sharedNullish(this: ZodType) { - return optional(nullable(this as any)) as any; -} -function _sharedNonoptional(this: ZodType, params?: any) { - return nonoptional(this as any, params) as any; -} -function _sharedArray(this: ZodType) { - return array(this as any) as any; -} -function _sharedOr(this: ZodType, arg: any) { - return union([this, arg] as any) as any; -} -function _sharedAnd(this: ZodType, arg: any) { - return intersection(this as any, arg) as any; -} -function _sharedTransform(this: ZodType, tx: any) { - return pipe(this as any, transform(tx as any)) as any; -} -function _sharedDefault(this: ZodType, d: any) { - return _default(this as any, d) as any; -} -function _sharedPrefault(this: ZodType, d: any) { - return prefault(this as any, d) as any; -} -function _sharedCatch(this: ZodType, params: any) { - return _catch(this as any, params) as any; -} -function _sharedPipe(this: ZodType, target: any) { - return pipe(this as any, target) as any; -} -function _sharedReadonly(this: ZodType) { - return readonly(this as any) as any; -} -function _sharedDescribe(this: ZodType, description: string) { - const cl = this.clone(); - core.globalRegistry.add(cl, { description }); - return cl as any; -} -function _sharedMeta(this: ZodType, ...args: any[]) { - if (args.length === 0) return core.globalRegistry.get(this as any); - const cl = this.clone(); - core.globalRegistry.add(cl, args[0]); - return cl as any; -} -function _sharedIsOptional(this: ZodType) { - return this.safeParse(undefined).success; -} -function _sharedIsNullable(this: ZodType) { - return this.safeParse(null).success; -} -function _sharedApply(this: ZodType, fn: (schema: any) => any) { - return fn(this); -} - -// _ZodString — string validation/transform shared methods -function _sharedRegex(this: any, ...args: any[]) { - return this.check((checks.regex as any)(...args)); -} -function _sharedIncludes(this: any, ...args: any[]) { - return this.check((checks.includes as any)(...args)); -} -function _sharedStartsWith(this: any, ...args: any[]) { - return this.check((checks.startsWith as any)(...args)); -} -function _sharedEndsWith(this: any, ...args: any[]) { - return this.check((checks.endsWith as any)(...args)); -} -function _sharedStrMin(this: any, ...args: any[]) { - return this.check((checks.minLength as any)(...args)); -} -function _sharedStrMax(this: any, ...args: any[]) { - return this.check((checks.maxLength as any)(...args)); -} -function _sharedStrLength(this: any, ...args: any[]) { - return this.check((checks.length as any)(...args)); -} -function _sharedStrNonempty(this: any, ...args: any[]) { - return this.check((checks.minLength as any)(1, ...args)); -} -function _sharedLowercase(this: any, params?: any) { - return this.check(checks.lowercase(params)); -} -function _sharedUppercase(this: any, params?: any) { - return this.check(checks.uppercase(params)); -} -function _sharedTrim(this: any) { - return this.check(checks.trim()); -} -function _sharedNormalize(this: any, ...args: any[]) { - return this.check(checks.normalize(...args)); -} -function _sharedToLowerCase(this: any) { - return this.check(checks.toLowerCase()); -} -function _sharedToUpperCase(this: any) { - return this.check(checks.toUpperCase()); -} -function _sharedSlugify(this: any) { - return this.check(checks.slugify()); -} - -// ZodNumber -function _sharedNumGt(this: any, value: number, params?: any) { - return this.check(checks.gt(value, params)); -} -function _sharedNumGte(this: any, value: number, params?: any) { - return this.check(checks.gte(value, params)); -} -function _sharedNumLt(this: any, value: number, params?: any) { - return this.check(checks.lt(value, params)); -} -function _sharedNumLte(this: any, value: number, params?: any) { - return this.check(checks.lte(value, params)); -} -function _sharedNumInt(this: any, params?: any) { - return this.check(int(params)); -} -function _sharedNumPositive(this: any, params?: any) { - return this.check(checks.gt(0, params)); -} -function _sharedNumNonnegative(this: any, params?: any) { - return this.check(checks.gte(0, params)); -} -function _sharedNumNegative(this: any, params?: any) { - return this.check(checks.lt(0, params)); -} -function _sharedNumNonpositive(this: any, params?: any) { - return this.check(checks.lte(0, params)); -} -function _sharedNumMultipleOf(this: any, value: number, params?: any) { - return this.check(checks.multipleOf(value, params)); -} -function _sharedNumFinite(this: any) { - return this; -} - -// ZodArray -function _sharedArrMin(this: any, n: number, params?: any) { - return this.check(checks.minLength(n, params)); -} -function _sharedArrMax(this: any, n: number, params?: any) { - return this.check(checks.maxLength(n, params)); -} -function _sharedArrLength(this: any, n: number, params?: any) { - return this.check(checks.length(n, params)); -} -function _sharedArrNonempty(this: any, params?: any) { - return this.check(checks.minLength(1, params)); -} -function _sharedArrUnwrap(this: any) { - return this.element; -} - -// ZodObject -function _sharedObjKeyof(this: any) { - return _enum(Object.keys(this._zod.def.shape)); -} -function _sharedObjCatchall(this: any, catchall: any) { - return this.clone({ ...this._zod.def, catchall }); -} -function _sharedObjPassthrough(this: any) { - return this.clone({ ...this._zod.def, catchall: unknown() }); -} -function _sharedObjLoose(this: any) { - return this.clone({ ...this._zod.def, catchall: unknown() }); -} -function _sharedObjStrict(this: any) { - return this.clone({ ...this._zod.def, catchall: never() }); -} -function _sharedObjStrip(this: any) { - return this.clone({ ...this._zod.def, catchall: undefined }); -} -function _sharedObjExtend(this: any, incoming: any) { - return util.extend(this, incoming); -} -function _sharedObjSafeExtend(this: any, incoming: any) { - return util.safeExtend(this, incoming); -} -function _sharedObjMerge(this: any, other: any) { - return util.merge(this, other); -} -function _sharedObjPick(this: any, mask: any) { - return util.pick(this, mask); -} -function _sharedObjOmit(this: any, mask: any) { - return util.omit(this, mask); -} -function _sharedObjPartial(this: any, ...args: any[]) { - return util.partial(ZodOptional, this, args[0]); -} -function _sharedObjRequired(this: any, ...args: any[]) { - return util.required(ZodNonOptional, this, args[0]); -} - -// Wrappers — shared unwrap (placeholder; will use when extending to wrapper types) -// @ts-ignore -function _sharedUnwrap(this: any) { - return this._zod.def.innerType; -} -// @ts-ignore -function _sharedLazyUnwrap(this: any) { - return this._zod.def.getter(); -} - /////////////////////////////////////////// /////////////////////////////////////////// //////////// //////////// @@ -466,33 +256,104 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct // detachability preserved (`const m = schema.optional; m()` works), and // shared underlying function references across all instances. _installLazyMethods(inst, "ZodType", { - check: _sharedCheck, - with: _sharedCheck, - clone: _sharedClone, - brand: _sharedBrand, - register: _sharedRegister, - refine: _sharedRefine, - superRefine: _sharedSuperRefine, - overwrite: _sharedOverwrite, - optional: _sharedOptional, - exactOptional: _sharedExactOptional, - nullable: _sharedNullable, - nullish: _sharedNullish, - nonoptional: _sharedNonoptional, - array: _sharedArray, - or: _sharedOr, - and: _sharedAnd, - transform: _sharedTransform, - default: _sharedDefault, - prefault: _sharedPrefault, - catch: _sharedCatch, - pipe: _sharedPipe, - readonly: _sharedReadonly, - describe: _sharedDescribe, - meta: _sharedMeta, - isOptional: _sharedIsOptional, - isNullable: _sharedIsNullable, - apply: _sharedApply, + check(...chks: any[]) { + const def = this.def; + return this.clone( + util.mergeDefs(def, { + checks: [ + ...(def.checks ?? []), + ...chks.map((ch: any) => + typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch + ), + ], + }), + { parent: true } + ); + }, + with(...chks: any[]) { + return this.check(...chks); + }, + clone(def?: any, params?: any) { + return core.clone(this, def, params); + }, + brand() { + return this; + }, + register(reg: any, meta: any) { + reg.add(this, meta); + return this; + }, + refine(check: any, params?: any) { + return this.check(refine(check, params)); + }, + superRefine(refinement: any, params?: any) { + return this.check(superRefine(refinement, params)); + }, + overwrite(fn: any) { + return this.check(checks.overwrite(fn)); + }, + optional() { + return optional(this); + }, + exactOptional() { + return exactOptional(this); + }, + nullable() { + return nullable(this); + }, + nullish() { + return optional(nullable(this)); + }, + nonoptional(params?: any) { + return nonoptional(this, params); + }, + array() { + return array(this); + }, + or(arg: any) { + return union([this, arg]); + }, + and(arg: any) { + return intersection(this, arg); + }, + transform(tx: any) { + return pipe(this, transform(tx)); + }, + default(d: any) { + return _default(this, d); + }, + prefault(d: any) { + return prefault(this, d); + }, + catch(params: any) { + return _catch(this, params); + }, + pipe(target: any) { + return pipe(this, target); + }, + readonly() { + return readonly(this); + }, + describe(description: string) { + const cl = this.clone(); + core.globalRegistry.add(cl, { description }); + return cl; + }, + meta(...args: any[]) { + if (args.length === 0) return core.globalRegistry.get(this); + const cl = this.clone(); + core.globalRegistry.add(cl, args[0]); + return cl; + }, + isOptional() { + return this.safeParse(undefined).success; + }, + isNullable() { + return this.safeParse(null).success; + }, + apply(fn: any) { + return fn(this); + }, }); Object.defineProperty(inst, "description", { get() { @@ -543,21 +404,51 @@ export const _ZodString: core.$constructor<_ZodString> = /*@__PURE__*/ core.$con inst.maxLength = bag.maximum ?? null; _installLazyMethods(inst, "_ZodString", { - regex: _sharedRegex, - includes: _sharedIncludes, - startsWith: _sharedStartsWith, - endsWith: _sharedEndsWith, - min: _sharedStrMin, - max: _sharedStrMax, - length: _sharedStrLength, - nonempty: _sharedStrNonempty, - lowercase: _sharedLowercase, - uppercase: _sharedUppercase, - trim: _sharedTrim, - normalize: _sharedNormalize, - toLowerCase: _sharedToLowerCase, - toUpperCase: _sharedToUpperCase, - slugify: _sharedSlugify, + regex(...args: any[]) { + return this.check((checks.regex as any)(...args)); + }, + includes(...args: any[]) { + return this.check((checks.includes as any)(...args)); + }, + startsWith(...args: any[]) { + return this.check((checks.startsWith as any)(...args)); + }, + endsWith(...args: any[]) { + return this.check((checks.endsWith as any)(...args)); + }, + min(...args: any[]) { + return this.check((checks.minLength as any)(...args)); + }, + max(...args: any[]) { + return this.check((checks.maxLength as any)(...args)); + }, + length(...args: any[]) { + return this.check((checks.length as any)(...args)); + }, + nonempty(...args: any[]) { + return this.check((checks.minLength as any)(1, ...args)); + }, + lowercase(params?: any) { + return this.check(checks.lowercase(params)); + }, + uppercase(params?: any) { + return this.check(checks.uppercase(params)); + }, + trim() { + return this.check(checks.trim()); + }, + normalize(...args: any[]) { + return this.check(checks.normalize(...args)); + }, + toLowerCase() { + return this.check(checks.toLowerCase()); + }, + toUpperCase() { + return this.check(checks.toUpperCase()); + }, + slugify() { + return this.check(checks.slugify()); + }, }); }); @@ -1111,21 +1002,51 @@ export const ZodNumber: core.$constructor = /*@__PURE__*/ core.$const inst._zod.processJSONSchema = (ctx, json, params) => processors.numberProcessor(inst, ctx, json, params); _installLazyMethods(inst, "ZodNumber", { - gt: _sharedNumGt, - gte: _sharedNumGte, - min: _sharedNumGte, - lt: _sharedNumLt, - lte: _sharedNumLte, - max: _sharedNumLte, - int: _sharedNumInt, - safe: _sharedNumInt, - positive: _sharedNumPositive, - nonnegative: _sharedNumNonnegative, - negative: _sharedNumNegative, - nonpositive: _sharedNumNonpositive, - multipleOf: _sharedNumMultipleOf, - step: _sharedNumMultipleOf, - finite: _sharedNumFinite, + gt(value: number, params?: any) { + return this.check(checks.gt(value, params)); + }, + gte(value: number, params?: any) { + return this.check(checks.gte(value, params)); + }, + min(value: number, params?: any) { + return this.check(checks.gte(value, params)); + }, + lt(value: number, params?: any) { + return this.check(checks.lt(value, params)); + }, + lte(value: number, params?: any) { + return this.check(checks.lte(value, params)); + }, + max(value: number, params?: any) { + return this.check(checks.lte(value, params)); + }, + int(params?: any) { + return this.check(int(params)); + }, + safe(params?: any) { + return this.check(int(params)); + }, + positive(params?: any) { + return this.check(checks.gt(0, params)); + }, + nonnegative(params?: any) { + return this.check(checks.gte(0, params)); + }, + negative(params?: any) { + return this.check(checks.lt(0, params)); + }, + nonpositive(params?: any) { + return this.check(checks.lte(0, params)); + }, + multipleOf(value: number, params?: any) { + return this.check(checks.multipleOf(value, params)); + }, + step(value: number, params?: any) { + return this.check(checks.multipleOf(value, params)); + }, + finite() { + return this; + }, }); const bag = inst._zod.bag; @@ -1410,11 +1331,21 @@ export const ZodArray: core.$constructor = /*@__PURE__*/ core.$constru inst.element = def.element; _installLazyMethods(inst, "ZodArray", { - min: _sharedArrMin, - nonempty: _sharedArrNonempty, - max: _sharedArrMax, - length: _sharedArrLength, - unwrap: _sharedArrUnwrap, + min(n: number, params?: any) { + return this.check(checks.minLength(n, params)); + }, + nonempty(params?: any) { + return this.check(checks.minLength(1, params)); + }, + max(n: number, params?: any) { + return this.check(checks.maxLength(n, params)); + }, + length(n: number, params?: any) { + return this.check(checks.length(n, params)); + }, + unwrap() { + return this.element; + }, }); }); @@ -1530,19 +1461,45 @@ export const ZodObject: core.$constructor = /*@__PURE__*/ core.$const }); _installLazyMethods(inst, "ZodObject", { - keyof: _sharedObjKeyof, - catchall: _sharedObjCatchall, - passthrough: _sharedObjPassthrough, - loose: _sharedObjLoose, - strict: _sharedObjStrict, - strip: _sharedObjStrip, - extend: _sharedObjExtend, - safeExtend: _sharedObjSafeExtend, - merge: _sharedObjMerge, - pick: _sharedObjPick, - omit: _sharedObjOmit, - partial: _sharedObjPartial, - required: _sharedObjRequired, + keyof() { + return _enum(Object.keys(this._zod.def.shape)); + }, + catchall(catchall: any) { + return this.clone({ ...this._zod.def, catchall }); + }, + passthrough() { + return this.clone({ ...this._zod.def, catchall: unknown() }); + }, + loose() { + return this.clone({ ...this._zod.def, catchall: unknown() }); + }, + strict() { + return this.clone({ ...this._zod.def, catchall: never() }); + }, + strip() { + return this.clone({ ...this._zod.def, catchall: undefined }); + }, + extend(incoming: any) { + return util.extend(this, incoming); + }, + safeExtend(incoming: any) { + return util.safeExtend(this, incoming); + }, + merge(other: any) { + return util.merge(this, other); + }, + pick(mask: any) { + return util.pick(this, mask); + }, + omit(mask: any) { + return util.omit(this, mask); + }, + partial(...args: any[]) { + return util.partial(ZodOptional, this, args[0]); + }, + required(...args: any[]) { + return util.required(ZodNonOptional, this, args[0]); + }, }); }); diff --git a/packages/zod/src/v4/core/core.ts b/packages/zod/src/v4/core/core.ts index d917c36e55..c191fdbe6f 100644 --- a/packages/zod/src/v4/core/core.ts +++ b/packages/zod/src/v4/core/core.ts @@ -9,57 +9,6 @@ export interface $constructor { init(inst: T, def: D): asserts inst is T; } -/** - * Symbol used to access the internal prototype of a Zod constructor. - * - * Each constructor created by `$constructor` maintains two prototype layers: - * inst → _.prototype (user-visible; copy-loop targets this) - * └── internalProto (library methods set by _initLazy go here) - * - * Library methods placed on internalProto are inherited (not own properties) - * which keeps instances under V8's fast-property threshold. - */ -export const $internalProto: unique symbol = Symbol("zod.internalProto"); - -/** - * Define a method on `proto` such that the FIRST time it is accessed on an - * instance, a `bind`-equivalent thunk is allocated and cached as an - * enumerable own property on that instance. Subsequent accesses skip the - * getter entirely. - * - * This preserves detached usage (`const m = schema.optional; m()` works - * because `m` is a bound function with `this` pre-resolved) while only - * allocating closures for methods actually accessed. - */ -export function defineLazy(proto: object, key: string | symbol, sharedFn: (...args: any[]) => any): void { - Object.defineProperty(proto, key, { - configurable: true, - enumerable: false, - get(this: any) { - // `bind` is roughly equivalent in cost to allocating a closure that - // captures `this`. We cache the result on the instance so this getter - // fires at most once per (instance, method). - const bound = sharedFn.bind(this); - Object.defineProperty(this, key, { - configurable: true, - writable: true, - enumerable: true, - value: bound, - }); - return bound; - }, - set(this: any, v: unknown) { - // Allow user reassignment / extension of the method on a specific instance. - Object.defineProperty(this, key, { - configurable: true, - writable: true, - enumerable: true, - value: v, - }); - }, - }); -} - /** A special constant with type `never` */ export const NEVER: never = /*@__PURE__*/ Object.freeze({ status: "aborted", @@ -106,13 +55,6 @@ export /*@__NO_SIDE_EFFECTS__*/ function $constructor { @@ -132,11 +73,6 @@ export /*@__NO_SIDE_EFFECTS__*/ function $constructor Date: Tue, 28 Apr 2026 21:36:09 -0700 Subject: [PATCH 3/5] refactor(v4): drop layer indirection from lazy-bind helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install lazy method getters directly on each concrete schema's `_.prototype` instead of inserting a library-owned layer between `_.prototype` and its parent. The layer was guarding `Object.keys(proto)` from seeing our methods, but the existing copy-loop in `core.$constructor` already uses `Object.keys` (enumerable own only), and our lazy getters are non-enumerable. So the layer wasn't doing real work — just adding prototype-chain depth and one `Object.setPrototypeOf` call per constructor (the only V8-deopt-prone op in the helper). Drops `_layerCache: WeakMap`, drops `Object.setPrototypeOf`, helper shrinks from 45 to 30 lines. Behavior: `'optional' in z.ZodString.prototype` is now `true` (was `false`) and `Object.getOwnPropertyNames(z.ZodString.prototype)` now lists the lazy method names — both arguably more intuitive since the methods conceptually do live on the prototype. Numbers (vs main, post-refactor): classic minimal bundle: +860 B raw / +175 B gz (was +212 gz) classic rich bundle: +1,135 B raw / +221 B gz (was +255 gz) mini bundles: byte-identical to main (unchanged) heap per instance: same -40% as before construction: same -25% as before All 3,743 tests pass. User prototype extensions still work (z.ZodType.prototype.x = ... copies to instances via the existing copy-loop, and explicitly setting an enumerable value on the prototype shadows the lazy getter as before). --- packages/zod/src/v4/classic/schemas.ts | 35 ++++++++++---------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index 571fed0d7f..fe019b769f 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -8,44 +8,35 @@ import * as checks from "./checks.js"; import * as iso from "./iso.js"; import * as parse from "./parse.js"; -// Lazy-bind layer for builder methods. +// Lazy-bind builder methods. // // Builder methods (`.optional`, `.array`, `.refine`, ...) live as -// non-enumerable getters on a library-owned prototype layer inserted -// between `constr.prototype` (where user extensions live) and the -// parent prototype. On first access from any instance the getter -// allocates `fn.bind(this)` and caches it as an own property on the -// instance — so detached usage (`const m = schema.optional; m()`) -// still works, and the per-instance allocation only happens for -// methods actually touched. +// non-enumerable getters on each concrete schema constructor's +// prototype. On first access from an instance the getter allocates +// `fn.bind(this)` and caches it as an own property on that instance, +// so detached usage (`const m = schema.optional; m()`) still works +// and the per-instance allocation only happens for methods actually +// touched. // -// One layer per concrete constructor; one install per (layer, group). -const _layerCache = /* @__PURE__ */ new WeakMap(); -const _installedGroups = /* @__PURE__ */ new WeakMap>(); +// One install per (prototype, group), memoized by `_installedGroups`. +const _installedGroups = /* @__PURE__ */ new WeakMap>(); function _installLazyMethods( inst: object, group: string, methods: Record any> ): void { - const constr = (inst as any)._zod.constr; - let layer = _layerCache.get(constr); - if (!layer) { - const userProto = constr.prototype; - layer = Object.create(Object.getPrototypeOf(userProto)); - Object.setPrototypeOf(userProto, layer); - _layerCache.set(constr, layer); - } - let installed = _installedGroups.get(layer); + const proto = Object.getPrototypeOf(inst); + let installed = _installedGroups.get(proto); if (!installed) { installed = new Set(); - _installedGroups.set(layer, installed); + _installedGroups.set(proto, installed); } if (installed.has(group)) return; installed.add(group); for (const key in methods) { const fn = methods[key]!; - Object.defineProperty(layer, key, { + Object.defineProperty(proto, key, { configurable: true, enumerable: false, get(this: any) { From 06afdd713001d264773fd83f6e34e4892e1ee8de Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 28 Apr 2026 21:45:49 -0700 Subject: [PATCH 4/5] refactor(v4): type-check lazy method bodies against schema interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `_installLazyMethods` typed `methods` as `Record any>`, which gave zero type safety on the inline method bodies — `this` was `any` and arguments were unchecked, so any signature drift between the body and the declared interface method silently slipped through. Replace with a generic helper that derives the expected method shapes from the instance's interface: type _LazyMethodsOf = Partial<{ [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (this: T, ...args: A) => R : never; }>; function _installLazyMethods( inst: T, group: string, methods: _LazyMethodsOf ): void Each call site now infers `T` from `inst` (e.g. `ZodType` / `_ZodString` / `ZodNumber` / `ZodArray` / `ZodObject`), and the inline method bodies are checked against the interface declarations. Adding/removing/renaming an arg in a body without updating the interface (or vice versa) is now a compile error pointing at both sites. The only body that needed adjustment is `meta()`, which is overloaded — the mapped type picks up the last overload, so we return `any` from the body to satisfy both the no-arg and 1-arg cases at runtime. All 3,743 tests pass; same 13 pre-existing tsc errors as `main`. --- packages/zod/src/v4/classic/schemas.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index fe019b769f..95a1c4b1fb 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -21,11 +21,17 @@ import * as parse from "./parse.js"; // One install per (prototype, group), memoized by `_installedGroups`. const _installedGroups = /* @__PURE__ */ new WeakMap>(); -function _installLazyMethods( - inst: object, - group: string, - methods: Record any> -): void { +/** + * Methods of `T` reshaped so each body has `this: T` and matches the + * declared (args, return) of the corresponding interface method. Allows + * us to type-check inline method-shorthand bodies against the + * `ZodType` / `_ZodString` / etc. interface declarations. + */ +type _LazyMethodsOf = Partial<{ + [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (this: T, ...args: A) => R : never; +}>; + +function _installLazyMethods(inst: T, group: string, methods: _LazyMethodsOf): void { const proto = Object.getPrototypeOf(inst); let installed = _installedGroups.get(proto); if (!installed) { @@ -330,7 +336,10 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct core.globalRegistry.add(cl, { description }); return cl; }, - meta(...args: any[]) { + meta(...args: any[]): any { + // overloaded: meta() returns the registered metadata, meta(data) + // returns a clone with `data` registered. The mapped type picks + // up the second overload, so we return `any` to satisfy both. if (args.length === 0) return core.globalRegistry.get(this); const cl = this.clone(); core.globalRegistry.add(cl, args[0]); From e5161617f3d7ecad54ff1c65cbc7c96389a1ff99 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 28 Apr 2026 21:52:26 -0700 Subject: [PATCH 5/5] refactor(v4): drop redundant `: any` annotations from lazy method bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the previous commit's strict typing on `_installLazyMethods`, parameters in the inline method bodies receive their types via contextual inference from the schema interfaces — explicit `: any` annotations on every arg were leftover noise from before. Strip them. Two methods need to keep variadic `any[]` because they intentionally diverge from the picked overload's static signature at runtime: - `meta(...args: any[]): any` — runtime branches on `args.length` to handle both `meta()` and `meta(data)` overloads, but the picked overload is `meta(data: GlobalMeta) => this`, which would type-narrow `args.length === 0` to "never true". - `catchall(catchall)` — needs `catchall as any` on the spread because `SomeType` isn't structurally compatible with the strict `$ZodType` shape `clone` expects. Everything else (~50 method bodies) loses its annotations and reads as `optional() { ... }`, `min(value, params) { ... }`, etc. with full contextual type-checking from the interface declarations. --- packages/zod/src/v4/classic/schemas.ts | 115 +++++++++++++------------ 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index 95a1c4b1fb..14479581d2 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -253,13 +253,13 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct // detachability preserved (`const m = schema.optional; m()` works), and // shared underlying function references across all instances. _installLazyMethods(inst, "ZodType", { - check(...chks: any[]) { + check(...chks) { const def = this.def; return this.clone( util.mergeDefs(def, { checks: [ ...(def.checks ?? []), - ...chks.map((ch: any) => + ...chks.map((ch) => typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch ), ], @@ -267,26 +267,26 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct { parent: true } ); }, - with(...chks: any[]) { + with(...chks) { return this.check(...chks); }, - clone(def?: any, params?: any) { + clone(def, params) { return core.clone(this, def, params); }, brand() { return this; }, - register(reg: any, meta: any) { + register(reg, meta) { reg.add(this, meta); return this; }, - refine(check: any, params?: any) { + refine(check, params) { return this.check(refine(check, params)); }, - superRefine(refinement: any, params?: any) { + superRefine(refinement, params) { return this.check(superRefine(refinement, params)); }, - overwrite(fn: any) { + overwrite(fn) { return this.check(checks.overwrite(fn)); }, optional() { @@ -301,37 +301,37 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct nullish() { return optional(nullable(this)); }, - nonoptional(params?: any) { + nonoptional(params) { return nonoptional(this, params); }, array() { return array(this); }, - or(arg: any) { + or(arg) { return union([this, arg]); }, - and(arg: any) { + and(arg) { return intersection(this, arg); }, - transform(tx: any) { + transform(tx) { return pipe(this, transform(tx)); }, - default(d: any) { + default(d) { return _default(this, d); }, - prefault(d: any) { + prefault(d) { return prefault(this, d); }, - catch(params: any) { + catch(params) { return _catch(this, params); }, - pipe(target: any) { + pipe(target) { return pipe(this, target); }, readonly() { return readonly(this); }, - describe(description: string) { + describe(description) { const cl = this.clone(); core.globalRegistry.add(cl, { description }); return cl; @@ -339,7 +339,8 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct meta(...args: any[]): any { // overloaded: meta() returns the registered metadata, meta(data) // returns a clone with `data` registered. The mapped type picks - // up the second overload, so we return `any` to satisfy both. + // up the second overload, so we accept variadic any-args and + // return `any` to satisfy both at runtime. if (args.length === 0) return core.globalRegistry.get(this); const cl = this.clone(); core.globalRegistry.add(cl, args[0]); @@ -351,7 +352,7 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct isNullable() { return this.safeParse(null).success; }, - apply(fn: any) { + apply(fn) { return fn(this); }, }); @@ -404,40 +405,40 @@ export const _ZodString: core.$constructor<_ZodString> = /*@__PURE__*/ core.$con inst.maxLength = bag.maximum ?? null; _installLazyMethods(inst, "_ZodString", { - regex(...args: any[]) { + regex(...args) { return this.check((checks.regex as any)(...args)); }, - includes(...args: any[]) { + includes(...args) { return this.check((checks.includes as any)(...args)); }, - startsWith(...args: any[]) { + startsWith(...args) { return this.check((checks.startsWith as any)(...args)); }, - endsWith(...args: any[]) { + endsWith(...args) { return this.check((checks.endsWith as any)(...args)); }, - min(...args: any[]) { + min(...args) { return this.check((checks.minLength as any)(...args)); }, - max(...args: any[]) { + max(...args) { return this.check((checks.maxLength as any)(...args)); }, - length(...args: any[]) { + length(...args) { return this.check((checks.length as any)(...args)); }, - nonempty(...args: any[]) { + nonempty(...args) { return this.check((checks.minLength as any)(1, ...args)); }, - lowercase(params?: any) { + lowercase(params) { return this.check(checks.lowercase(params)); }, - uppercase(params?: any) { + uppercase(params) { return this.check(checks.uppercase(params)); }, trim() { return this.check(checks.trim()); }, - normalize(...args: any[]) { + normalize(...args) { return this.check(checks.normalize(...args)); }, toLowerCase() { @@ -1002,46 +1003,46 @@ export const ZodNumber: core.$constructor = /*@__PURE__*/ core.$const inst._zod.processJSONSchema = (ctx, json, params) => processors.numberProcessor(inst, ctx, json, params); _installLazyMethods(inst, "ZodNumber", { - gt(value: number, params?: any) { + gt(value, params) { return this.check(checks.gt(value, params)); }, - gte(value: number, params?: any) { + gte(value, params) { return this.check(checks.gte(value, params)); }, - min(value: number, params?: any) { + min(value, params) { return this.check(checks.gte(value, params)); }, - lt(value: number, params?: any) { + lt(value, params) { return this.check(checks.lt(value, params)); }, - lte(value: number, params?: any) { + lte(value, params) { return this.check(checks.lte(value, params)); }, - max(value: number, params?: any) { + max(value, params) { return this.check(checks.lte(value, params)); }, - int(params?: any) { + int(params) { return this.check(int(params)); }, - safe(params?: any) { + safe(params) { return this.check(int(params)); }, - positive(params?: any) { + positive(params) { return this.check(checks.gt(0, params)); }, - nonnegative(params?: any) { + nonnegative(params) { return this.check(checks.gte(0, params)); }, - negative(params?: any) { + negative(params) { return this.check(checks.lt(0, params)); }, - nonpositive(params?: any) { + nonpositive(params) { return this.check(checks.lte(0, params)); }, - multipleOf(value: number, params?: any) { + multipleOf(value, params) { return this.check(checks.multipleOf(value, params)); }, - step(value: number, params?: any) { + step(value, params) { return this.check(checks.multipleOf(value, params)); }, finite() { @@ -1331,16 +1332,16 @@ export const ZodArray: core.$constructor = /*@__PURE__*/ core.$constru inst.element = def.element; _installLazyMethods(inst, "ZodArray", { - min(n: number, params?: any) { + min(n, params) { return this.check(checks.minLength(n, params)); }, - nonempty(params?: any) { + nonempty(params) { return this.check(checks.minLength(1, params)); }, - max(n: number, params?: any) { + max(n, params) { return this.check(checks.maxLength(n, params)); }, - length(n: number, params?: any) { + length(n, params) { return this.check(checks.length(n, params)); }, unwrap() { @@ -1464,8 +1465,8 @@ export const ZodObject: core.$constructor = /*@__PURE__*/ core.$const keyof() { return _enum(Object.keys(this._zod.def.shape)); }, - catchall(catchall: any) { - return this.clone({ ...this._zod.def, catchall }); + catchall(catchall) { + return this.clone({ ...this._zod.def, catchall: catchall as any }); }, passthrough() { return this.clone({ ...this._zod.def, catchall: unknown() }); @@ -1479,25 +1480,25 @@ export const ZodObject: core.$constructor = /*@__PURE__*/ core.$const strip() { return this.clone({ ...this._zod.def, catchall: undefined }); }, - extend(incoming: any) { + extend(incoming) { return util.extend(this, incoming); }, - safeExtend(incoming: any) { + safeExtend(incoming) { return util.safeExtend(this, incoming); }, - merge(other: any) { + merge(other) { return util.merge(this, other); }, - pick(mask: any) { + pick(mask) { return util.pick(this, mask); }, - omit(mask: any) { + omit(mask) { return util.omit(this, mask); }, - partial(...args: any[]) { + partial(...args) { return util.partial(ZodOptional, this, args[0]); }, - required(...args: any[]) { + required(...args) { return util.required(ZodNonOptional, this, args[0]); }, });