diff --git a/packages/bench/object-moltar-jitless.ts b/packages/bench/object-moltar-jitless.ts new file mode 100644 index 0000000000..6cc4671125 --- /dev/null +++ b/packages/bench/object-moltar-jitless.ts @@ -0,0 +1,85 @@ +import * as z4 from "zod/v4"; +import * as z3 from "zod3"; +import * as z4lib from "zod4/v4"; +import { metabench } from "./metabench.js"; + +z4.config({ + jitless: true, +}); + +const z3Schema = z3.strictObject({ + number: z3.number(), + negNumber: z3.number(), + maxNumber: z3.number(), + string: z3.string(), + longString: z3.string(), + boolean: z3.boolean(), + deeplyNested: z3.strictObject({ + foo: z3.string(), + num: z3.number(), + bool: z3.boolean(), + }), +}); + +const z4LibSchema = z4lib.strictObject({ + number: z4lib.number(), + negNumber: z4lib.number(), + maxNumber: z4lib.number(), + string: z4lib.string(), + longString: z4lib.string(), + boolean: z4lib.boolean(), + deeplyNested: z4lib.strictObject({ + foo: z4lib.string(), + num: z4lib.number(), + bool: z4lib.boolean(), + }), +}); + +const z4Schema = z4.strictObject({ + number: z4.number(), + negNumber: z4.number(), + maxNumber: z4.number(), + string: z4.string(), + longString: z4.string(), + boolean: z4.boolean(), + deeplyNested: z4.strictObject({ + foo: z4.string(), + num: z4.number(), + bool: z4.boolean(), + }), +}); + +const DATA = Array.from({ length: 1000 }, () => + Object.freeze({ + number: 1, + negNumber: -1, + maxNumber: Number.MAX_VALUE, + string: "string", + longString: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Vivendum intellegat et qui, ei denique consequuntur vix. Semper aeterno percipit ut his, sea ex utinam referrentur repudiandae. No epicuri hendrerit consetetur sit, sit dicta adipiscing ex, in facete detracto deterruisset duo. Quot populo ad qui. Sit fugit nostrum et. Ad per diam dicant interesset, lorem iusto sensibus ut sed. No dicam aperiam vis. Pri posse graeco definitiones cu, id eam populo quaestio adipiscing, usu quod malorum te. Ex nam agam veri, dicunt efficiantur ad qui, ad legere adversarium sit. Commune platonem mel id, brute adipiscing duo an. Vivendum intellegat et qui, ei denique consequuntur vix. Offendit eleifend moderatius ex vix, quem odio mazim et qui, purto expetendis cotidieque quo cu, veri persius vituperata ei nec. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + boolean: true, + deeplyNested: { + foo: "bar", + num: 1, + bool: false, + }, + }) +); + +console.log(z3Schema.parse(DATA[0])); +console.log(z4Schema.parse(DATA[0])); +console.log(z4LibSchema.parse(DATA[0])); + +const bench = metabench("z.object() safeParse", { + zod3() { + for (const _ of DATA) z3Schema.parse(_); + }, + zod4lib() { + for (const _ of DATA) z4LibSchema.parse(_); + }, + zod4() { + for (const _ of DATA) z4Schema.parse(_); + }, +}); + +await bench.run(); diff --git a/packages/bench/object-moltar.ts b/packages/bench/object-moltar.ts index cd5713ef27..e857077669 100644 --- a/packages/bench/object-moltar.ts +++ b/packages/bench/object-moltar.ts @@ -45,34 +45,6 @@ const z4Schema = z4.strictObject({ }), }); -// const z4SchemaStrict = z4.strictObject({ -// number: z4.number(), -// negNumber: z4.number(), -// maxNumber: z4.number(), -// string: z4.string(), -// longString: z4.string(), -// boolean: z4.boolean(), -// deeplyNested: z4.strictObject({ -// foo: z4.string(), -// num: z4.number(), -// bool: z4.boolean(), -// }), -// }); - -// const z4SchemaLoose = z4.object({ -// number: z4.number(), -// negNumber: z4.number(), -// maxNumber: z4.number(), -// string: z4.string(), -// longString: z4.string(), -// boolean: z4.boolean(), -// deeplyNested: z4.object({ -// foo: z4.string(), -// num: z4.number(), -// bool: z4.boolean(), -// }), -// }); - const DATA = Array.from({ length: 1000 }, () => Object.freeze({ number: 1, diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index e6e73513d9..643873460c 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -100,6 +100,7 @@ export interface ZodType< // wrappers optional(): ZodOptional; + exactOptional(): ZodExactOptional; nonoptional(params?: string | core.$ZodNonOptionalParams): ZodNonOptional; nullable(): ZodNullable; nullish(): ZodOptional>; @@ -214,6 +215,7 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct // wrappers inst.optional = () => optional(inst); + inst.exactOptional = () => exactOptional(inst); inst.nullable = () => nullable(inst); inst.nullish = () => optional(nullable(inst)); inst.nonoptional = (params) => nonoptional(inst, params); @@ -1845,6 +1847,31 @@ export function optional(innerType: T): ZodOptional }) as any; } +// ZodExactOptional +export interface ZodExactOptional + extends _ZodType>, + core.$ZodExactOptional { + "~standard": ZodStandardSchemaWithJSON; + unwrap(): T; +} +export const ZodExactOptional: core.$constructor = /*@__PURE__*/ core.$constructor( + "ZodExactOptional", + (inst, def) => { + core.$ZodExactOptional.init(inst, def); + ZodType.init(inst, def); + inst._zod.processJSONSchema = (ctx, json, params) => processors.optionalProcessor(inst, ctx, json, params); + + inst.unwrap = () => inst._zod.def.innerType; + } +); + +export function exactOptional(innerType: T): ZodExactOptional { + return new ZodExactOptional({ + type: "optional", + innerType: innerType as any as core.$ZodType, + }) as any; +} + // ZodNullable export interface ZodNullable extends _ZodType>, diff --git a/packages/zod/src/v4/classic/tests/optional.test.ts b/packages/zod/src/v4/classic/tests/optional.test.ts index 593221cf57..7901935abd 100644 --- a/packages/zod/src/v4/classic/tests/optional.test.ts +++ b/packages/zod/src/v4/classic/tests/optional.test.ts @@ -134,3 +134,90 @@ test("optional prop with pipe", () => { schema.parse({}); schema.parse({}, { jitless: true }); }); + +// exactOptional tests +test(".exactOptional()", () => { + const schema = z.string().exactOptional(); + expect(schema.parse("asdf")).toEqual("asdf"); + expect(schema.safeParse(undefined).success).toEqual(false); + expect(schema.safeParse(null).success).toEqual(false); + + // Type should NOT include undefined + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); +}); + +test("exactOptional unwrap", () => { + const unwrapped = z.string().exactOptional().unwrap(); + expect(unwrapped).toBeInstanceOf(z.ZodString); +}); + +test("exactOptional optionality", () => { + const a = z.string().exactOptional(); + expect(a._zod.optin).toEqual("optional"); + expect(a._zod.optout).toEqual("optional"); + expectTypeOf().toEqualTypeOf<"optional">(); + expectTypeOf().toEqualTypeOf<"optional">(); +}); + +test("exactOptional in objects - absent keys", () => { + const schema = z.object({ + a: z.string().exactOptional(), + }); + + // Absent key should pass + expect(schema.parse({})).toEqual({}); + expect(schema.parse({}, { jitless: true })).toEqual({}); + + // Present key with valid value should pass + expect(schema.parse({ a: "hello" })).toEqual({ a: "hello" }); + expect(schema.parse({ a: "hello" }, { jitless: true })).toEqual({ a: "hello" }); +}); + +test("exactOptional in objects - explicit undefined rejected", () => { + const schema = z.object({ + a: z.string().exactOptional(), + }); + + // Explicit undefined should fail + expect(schema.safeParse({ a: undefined }).success).toEqual(false); + expect(schema.safeParse({ a: undefined }, { jitless: true }).success).toEqual(false); +}); + +test("exactOptional type inference in objects", () => { + const schema = z.object({ + a: z.string().exactOptional(), + b: z.string().optional(), + }); + + type SchemaIn = z.input; + expectTypeOf().toEqualTypeOf<{ + a?: string; + b?: string | undefined; + }>(); + + type SchemaOut = z.output; + expectTypeOf().toEqualTypeOf<{ + a?: string; + b?: string | undefined; + }>(); +}); + +test("exactOptional vs optional comparison", () => { + const optionalSchema = z.object({ a: z.string().optional() }); + const exactOptionalSchema = z.object({ a: z.string().exactOptional() }); + + // Both accept absent keys + expect(optionalSchema.parse({})).toEqual({}); + expect(exactOptionalSchema.parse({})).toEqual({}); + + // Both accept valid values + expect(optionalSchema.parse({ a: "hi" })).toEqual({ a: "hi" }); + expect(exactOptionalSchema.parse({ a: "hi" })).toEqual({ a: "hi" }); + + // optional() accepts explicit undefined + expect(optionalSchema.parse({ a: undefined })).toEqual({ a: undefined }); + + // exactOptional() rejects explicit undefined + expect(exactOptionalSchema.safeParse({ a: undefined }).success).toEqual(false); +}); diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts index 43a5abb726..3fefc26ce4 100644 --- a/packages/zod/src/v4/core/schemas.ts +++ b/packages/zod/src/v4/core/schemas.ts @@ -1704,8 +1704,18 @@ export type $InferObjectInput; -function handlePropertyResult(result: ParsePayload, final: ParsePayload, key: PropertyKey, input: any) { +function handlePropertyResult( + result: ParsePayload, + final: ParsePayload, + key: PropertyKey, + input: any, + isOptionalOut: boolean +) { if (result.issues.length) { + // For optional-out schemas, ignore errors on absent keys + if (isOptionalOut && !(key in input)) { + return; + } final.issues.push(...util.prefixIssues(key, result.issues)); } @@ -1799,6 +1809,7 @@ function handleCatchall( const keySet = def.keySet; const _catchall = def.catchall!._zod; const t = _catchall.def.type; + const isOptionalOut = _catchall.optout === "optional"; for (const key in input) { if (keySet.has(key)) continue; if (t === "never") { @@ -1808,9 +1819,9 @@ function handleCatchall( const r = _catchall.run({ value: input[key], issues: [] }, ctx); if (r instanceof Promise) { - proms.push(r.then((r) => handlePropertyResult(r, payload, key, input))); + proms.push(r.then((r) => handlePropertyResult(r, payload, key, input, isOptionalOut))); } else { - handlePropertyResult(r, payload, key, input); + handlePropertyResult(r, payload, key, input, isOptionalOut); } } @@ -1888,11 +1899,13 @@ export const $ZodObject: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$con for (const key of value.keys) { const el = shape[key]!; + const isOptionalOut = el._zod.optout === "optional"; + const r = el._zod.run({ value: input[key], issues: [] }, ctx); if (r instanceof Promise) { - proms.push(r.then((r) => handlePropertyResult(r, payload, key, input))); + proms.push(r.then((r) => handlePropertyResult(r, payload, key, input, isOptionalOut))); } else { - handlePropertyResult(r, payload, key, input); + handlePropertyResult(r, payload, key, input, isOptionalOut); } } @@ -1935,8 +1948,34 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$ for (const key of normalized.keys) { const id = ids[key]; const k = util.esc(key); + const schema = shape[key]; + const isOptionalOut = schema?._zod?.optout === "optional"; + doc.write(`const ${id} = ${parseStr(key)};`); - doc.write(` + + if (isOptionalOut) { + // For optional-out schemas, ignore errors on absent keys + doc.write(` + if (${id}.issues.length) { + if (${k} in input) { + payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ + ...iss, + path: iss.path ? [${k}, ...iss.path] : [${k}] + }))); + } + } + + if (${id}.value === undefined) { + if (${k} in input) { + newResult[${k}] = undefined; + } + } else { + newResult[${k}] = ${id}.value; + } + + `); + } else { + doc.write(` if (${id}.issues.length) { payload.issues = payload.issues.concat(${id}.issues.map(iss => ({ ...iss, @@ -1944,7 +1983,6 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$ }))); } - if (${id}.value === undefined) { if (${k} in input) { newResult[${k}] = undefined; @@ -1954,6 +1992,7 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$ } `); + } } doc.write(`payload.value = newResult;`); @@ -3290,6 +3329,45 @@ export const $ZodOptional: core.$constructor<$ZodOptional> = /*@__PURE__*/ core. } ); +//////////////////////////////////////////////// +//////////////////////////////////////////////// +////////// ////////// +////////// $ZodExactOptional ////////// +////////// ////////// +//////////////////////////////////////////////// +//////////////////////////////////////////////// + +// Def extends $ZodOptionalDef (no additional fields needed) +export interface $ZodExactOptionalDef extends $ZodOptionalDef {} + +// Internals extends $ZodOptionalInternals but narrows output/input types (removes | undefined) +export interface $ZodExactOptionalInternals extends $ZodOptionalInternals { + def: $ZodExactOptionalDef; + output: core.output; // NO | undefined (narrowed from parent) + input: core.input; // NO | undefined (narrowed from parent) +} + +export interface $ZodExactOptional extends $ZodType { + _zod: $ZodExactOptionalInternals; +} + +export const $ZodExactOptional: core.$constructor<$ZodExactOptional> = /*@__PURE__*/ core.$constructor( + "$ZodExactOptional", + (inst, def) => { + // Call parent init - inherits optin/optout = "optional" + $ZodOptional.init(inst, def); + + // Override values/pattern to NOT add undefined + util.defineLazy(inst._zod, "values", () => def.innerType._zod.values); + util.defineLazy(inst._zod, "pattern", () => def.innerType._zod.pattern); + + // Override parse to just delegate (no undefined handling) + inst._zod.parse = (payload, ctx) => { + return def.innerType._zod.run(payload, ctx); + }; + } +); + //////////////////////////////////////////// //////////////////////////////////////////// ////////// ////////// diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts index 2c00bace0a..e2a3ea376f 100644 --- a/packages/zod/src/v4/mini/schemas.ts +++ b/packages/zod/src/v4/mini/schemas.ts @@ -1385,6 +1385,28 @@ export function optional(innerType: T): ZodMiniOptional { }) as any; } +// ZodMiniExactOptional +export interface ZodMiniExactOptional + extends _ZodMiniType>, + core.$ZodExactOptional { + // _zod: core.$ZodExactOptionalInternals; +} +export const ZodMiniExactOptional: core.$constructor = /*@__PURE__*/ core.$constructor( + "ZodMiniExactOptional", + (inst, def) => { + core.$ZodExactOptional.init(inst, def); + ZodMiniType.init(inst, def); + } +); + +// @__NO_SIDE_EFFECTS__ +export function exactOptional(innerType: T): ZodMiniExactOptional { + return new ZodMiniExactOptional({ + type: "optional", + innerType: innerType as any as core.$ZodType, + }) as any; +} + // ZodMiniNullable export interface ZodMiniNullable extends _ZodMiniType> { diff --git a/play.ts b/play.ts index 9b3a6804a0..9045f2bb9e 100644 --- a/play.ts +++ b/play.ts @@ -1,3 +1,7 @@ import * as z from "./packages/zod/src/v4/index.js"; -z; +const A = z.object({ + a: z.string().optional(), +}); + +console.log(A.parse({}));