diff --git a/packages/docs/content/api.mdx b/packages/docs/content/api.mdx index 12cb0fa79c..8d9eaf17ad 100644 --- a/packages/docs/content/api.mdx +++ b/packages/docs/content/api.mdx @@ -1269,8 +1269,64 @@ const Extended = z.safeExtend(UserSchema, { -### `.pick()` +### `.change()` + +The `.change()` method allows you to modify existing properties in an object schema. Unlike `.extend()`, which adds or overwrites properties, `.change()` specifically targets existing properties for modification. + +```ts +const User = z.object({ + name: z.string(), + age: z.number() +}); + +const StrictUser = User.change({ + name: z.string().min(5) // Make name more restrictive +}); +``` + +### `.safeChange()` + +The `.safeChange()` method works similarly to `.change()`, but it won't let you modify an existing property with a non-assignable schema. This ensures type safety by maintaining compatibility with the original schema. + +```ts +z.object({ a: z.string() }).safeChange({ a: z.string().min(5) }); // ✅ +z.object({ a: z.string() }).safeChange({ a: z.any() }); // ✅ +z.object({ a: z.string() }).safeChange({ a: z.number() }); +// ^ ❌ ZodNumber is not assignable +``` + + + +```ts +const Base = z.object({ + email: z.string(), + password: z.string() +}); + +// Safely change existing properties +const Enhanced = Base.safeChange({ + email: z.string().email(), + password: z.string().min(8) +}); +``` + + +```ts +const Base = z.object({ + email: z.string(), + password: z.string() +}); +// Safely change existing properties +const Enhanced = z.safeChange(Base, { + email: z.string().email(), + password: z.string().min(8) +}); +``` + + + +### `.pick()` Inspired by TypeScript's built-in `Pick` and `Omit` utility types, Zod provides dedicated APIs for picking and omitting certain keys from an object schema. Starting from this initial schema: diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index 08147140eb..2a56f2a372 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -151,7 +151,11 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct checks: [ ...(def.checks ?? []), ...checks.map((ch) => - typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch + typeof ch === "function" + ? { + _zod: { check: ch, def: { check: "custom" }, onattach: [] }, + } + : ch ), ], } @@ -1113,6 +1117,16 @@ export type SafeExtendShape = { + [K in keyof Changes]: K extends keyof Base + ? core.output extends core.output + ? core.input extends core.input + ? Changes[K] + : never + : never + : never; // SafeChange doesn't allow new properties, only existing ones +}; + export interface ZodObject< /** @ts-ignore Cast variance */ out Shape extends core.$ZodShape = core.$ZodLooseShape, @@ -1142,6 +1156,12 @@ export interface ZodObject< shape: SafeExtendShape & Partial> ): ZodObject, Config>; + change>>(shape: U): ZodObject, Config>; + + safeChange>>( + shape: SafeChangeShape & Partial> + ): ZodObject, Config>; + /** * @deprecated Use [`A.extend(B.shape)`](https://zod.dev/api?id=extend) instead. */ @@ -1198,7 +1218,11 @@ export const ZodObject: core.$constructor = /*@__PURE__*/ core.$const util.defineLazy(inst, "shape", () => 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.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() }); @@ -1210,6 +1234,12 @@ export const ZodObject: core.$constructor = /*@__PURE__*/ core.$const inst.safeExtend = (incoming: any) => { return util.safeExtend(inst, incoming); }; + inst.change = (incoming: any) => { + return util.change(inst, incoming); + }; + inst.safeChange = (incoming: any) => { + return util.safeChange(inst, incoming); + }; inst.merge = (other) => util.merge(inst, other); inst.pick = (mask) => util.pick(inst, mask); inst.omit = (mask) => util.omit(inst, mask); @@ -2065,10 +2095,7 @@ export function _function = Array = Array, const Out extends core.$ZodFunctionOut = core.$ZodFunctionOut, ->(params: { - input: In; - output: Out; -}): ZodFunction, Out>; +>(params: { input: In; output: Out }): ZodFunction, Out>; export function _function(params: { input: In; }): ZodFunction; @@ -2078,10 +2105,7 @@ export function _function(params?: { - input: In; - output: Out; -}): ZodFunction; +>(params?: { input: In; output: Out }): ZodFunction; export function _function(params?: { output?: core.$ZodType; input?: core.$ZodFunctionArgs | Array; diff --git a/packages/zod/src/v4/core/tests/change.test.ts b/packages/zod/src/v4/core/tests/change.test.ts new file mode 100644 index 0000000000..16aaa3530e --- /dev/null +++ b/packages/zod/src/v4/core/tests/change.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from "vitest"; +import * as z from "zod/v4"; + +test("change can modify existing properties", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + }); + + const schema2 = schema1.change({ + email: z.string().email(), + age: z.number().min(18), + }); + + // Should parse valid data + const result = schema2.parse({ email: "test@example.com", age: 25 }); + expect(result).toEqual({ email: "test@example.com", age: 25 }); + + // Should fail validation on invalid email + expect(() => schema2.parse({ email: "invalid-email", age: 25 })).toThrow(); + + // Should fail validation on age < 18 + expect(() => schema2.parse({ email: "test@example.com", age: 16 })).toThrow(); +}); + +test("change can partially modify properties", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + name: z.string(), + }); + + // Only change age, keep email and name as they were + const schema2 = schema1.change({ + age: z.number().min(18), + }); + + const result = schema2.parse({ + email: "test@example.com", + age: 25, + name: "John", + }); + expect(result).toEqual({ email: "test@example.com", age: 25, name: "John" }); +}); + +test("change throws error when adding new properties", () => { + const schema1 = z.object({ + email: z.string(), + }); + + // This should work - existing property + const validChange = schema1.change({ + email: z.string().email(), + }); + expect(validChange).toBeDefined(); + + // TypeScript correctly prevents this at compile time with: + // Error: 'newProperty' does not exist in type 'Partial>' + expect(() => { + schema1.change({ + // @ts-expect-error + newProperty: z.string(), + }); + }).toThrow('Cannot change non-existing property: "newProperty"'); +}); + +test("change throws error for completely new properties", () => { + const schema1 = z.object({ + email: z.string(), + }); + + expect(() => { + schema1.change({ + // @ts-expect-error + nonExistentProp: z.number(), + }); + }).toThrow('Cannot change non-existing property: "nonExistentProp"'); +}); + +test("change preserves type inference", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + }); + + const schema2 = schema1.change({ + email: z.string().email(), + }); + + // TypeScript should infer the correct type + type Schema2Type = z.infer; + const _typeTest: Schema2Type = { email: "test@example.com", age: 25 }; + // Just ensure the variable exists to satisfy TypeScript + expect(_typeTest).toBeDefined(); +}); + +test("change works with schema containing refinements using safeChange", () => { + const schema1 = z + .object({ + email: z.string(), + age: z.number(), + }) + .refine((data) => data.age >= 0, { message: "Age must be non-negative" }); + + // change() should throw with refinements + expect(() => { + schema1.change({ email: z.string().email() }); + }).toThrow("Object schemas containing refinements cannot be changed. Use `.safeChange()` instead."); + + // safeChange() should work + const schema2 = schema1.safeChange({ + email: z.string().email(), + }); + + const result = schema2.parse({ email: "test@example.com", age: 25 }); + expect(result).toEqual({ email: "test@example.com", age: 25 }); +}); + +test("safeChange throws error when adding new properties", () => { + const schema1 = z.object({ + email: z.string(), + }); + + expect(() => { + schema1.safeChange({ + email: z.string().email(), + // @ts-expect-error + newProperty: z.string(), + }); + }).toThrow('Cannot change non-existing property: "newProperty"'); +}); + +test("change chaining preserves and overrides properties", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + }); + + const schema2 = schema1.change({ + email: z.string().email(), + }); + + const schema3 = schema2.change({ + email: z.string().email().or(z.literal("")), + age: z.number().min(18), + }); + + // Should accept valid email and age + schema3.parse({ email: "test@example.com", age: 25 }); + + // Should accept empty string email + schema3.parse({ email: "", age: 25 }); + + // Should reject invalid age + expect(() => schema3.parse({ email: "", age: 16 })).toThrow(); +}); diff --git a/packages/zod/src/v4/core/util.ts b/packages/zod/src/v4/core/util.ts index d5b6ad5069..02ef13dbea 100644 --- a/packages/zod/src/v4/core/util.ts +++ b/packages/zod/src/v4/core/util.ts @@ -606,7 +606,9 @@ export function omit(schema: schemas.$ZodObject, mask: object): any { const def = mergeDefs(schema._zod.def, { get shape() { - const newShape: Writeable = { ...schema._zod.def.shape }; + const newShape: Writeable = { + ...schema._zod.def.shape, + }; for (const key in mask) { if (!(key in currDef.shape)) { throw new Error(`Unrecognized key: "${key}"`); @@ -662,6 +664,63 @@ export function safeExtend(schema: schemas.$ZodObject, shape: schemas.$ZodShape) return clone(schema, def) as any; } +export function change(schema: schemas.$ZodObject, shape: schemas.$ZodShape): any { + if (!isPlainObject(shape)) { + throw new Error("Invalid input to change: expected a plain object"); + } + + const existingShape = schema._zod.def.shape; + + // Validate that all keys in shape exist in the original schema + for (const key in shape) { + if (!(key in existingShape)) { + throw new Error(`Cannot change non-existing property: "${key}"`); + } + } + + const checks = schema._zod.def.checks; + const hasChecks = checks && checks.length > 0; + if (hasChecks) { + throw new Error("Object schemas containing refinements cannot be changed. Use `.safeChange()` instead."); + } + + const def = mergeDefs(schema._zod.def, { + get shape() { + const _shape = { ...existingShape, ...shape }; + assignProp(this, "shape", _shape); // self-caching + return _shape; + }, + checks: [], + }); + return clone(schema, def) as any; +} + +export function safeChange(schema: schemas.$ZodObject, shape: schemas.$ZodShape): any { + if (!isPlainObject(shape)) { + throw new Error("Invalid input to safeChange: expected a plain object"); + } + + const existingShape = schema._zod.def.shape; + + // Validate that all keys in shape exist in the original schema + for (const key in shape) { + if (!(key in existingShape)) { + throw new Error(`Cannot change non-existing property: "${key}"`); + } + } + + const def = { + ...schema._zod.def, + get shape() { + const _shape = { ...existingShape, ...shape }; + assignProp(this, "shape", _shape); // self-caching + return _shape; + }, + checks: schema._zod.def.checks, + } as any; + return clone(schema, def) as any; +} + export function merge(a: schemas.$ZodObject, b: schemas.$ZodObject): any { const def = mergeDefs(a._zod.def, { get shape() { diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts index 1c05c358cc..79465d091b 100644 --- a/packages/zod/src/v4/mini/schemas.ts +++ b/packages/zod/src/v4/mini/schemas.ts @@ -58,7 +58,11 @@ export const ZodMiniType: core.$constructor = /*@__PURE__*/ core.$c checks: [ ...(def.checks ?? []), ...checks.map((ch) => - typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch + typeof ch === "function" + ? { + _zod: { check: ch, def: { check: "custom" }, onattach: [] }, + } + : ch ), ], } @@ -833,6 +837,16 @@ export type SafeExtendShape = { + [K in keyof Changes]: K extends keyof Base + ? core.output extends core.output + ? core.input extends core.input + ? Changes[K] + : never + : never + : never; +}; + export function safeExtend( schema: T, shape: SafeExtendShape @@ -840,6 +854,20 @@ export function safeExtend>>( + schema: T, + shape: U +): ZodMiniObject, T["_zod"]["config"]> { + return util.change(schema, shape as any); +} + +export function safeChange>>( + schema: T, + shape: SafeChangeShape +): ZodMiniObject, T["_zod"]["config"]> { + return util.safeChange(schema, shape as any); +} + /** @deprecated Identical to `z.extend(A, B)` */ export function merge( a: T, @@ -1721,10 +1749,7 @@ export function _function(params?: { - input: In; - output: Out; -}): ZodMiniFunction; +>(params?: { input: In; output: Out }): ZodMiniFunction; export function _function(params?: { output?: core.$ZodFunctionOut; input?: core.$ZodFunctionArgs | Array; diff --git a/packages/zod/src/v4/mini/tests/change.test.ts b/packages/zod/src/v4/mini/tests/change.test.ts new file mode 100644 index 0000000000..ddd4b6cd24 --- /dev/null +++ b/packages/zod/src/v4/mini/tests/change.test.ts @@ -0,0 +1,190 @@ +import { expect, test } from "vitest"; +import * as z from "zod/mini"; + +test("change can modify existing properties", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + }); + + const schema2 = z.change(schema1, { + email: z.string(), + age: z.number(), + }); + + // Should parse valid data + const result = schema2.parse({ email: "test@example.com", age: 25 }); + expect(result).toEqual({ email: "test@example.com", age: 25 }); +}); + +test("change can partially modify properties", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + name: z.string(), + }); + + // Only change age, keep email and name as they were + const schema2 = z.change(schema1, { + age: z.number(), + }); + + const result = schema2.parse({ + email: "test@example.com", + age: 25, + name: "John", + }); + expect(result).toEqual({ email: "test@example.com", age: 25, name: "John" }); +}); + +test("change throws error when adding new properties", () => { + const schema1 = z.object({ + email: z.string(), + }); + + // This should work - existing property + const validChange = z.change(schema1, { + email: z.string(), + }); + expect(validChange).toBeDefined(); + + // TypeScript should prevent this, test runtime error with explicit bypass + expect(() => { + z.change(schema1, { + // @ts-expect-error + newProperty: z.string(), + }); + }).toThrow('Cannot change non-existing property: "newProperty"'); +}); + +test("change preserves type inference", () => { + const schema1 = z.object({ + email: z.string(), + age: z.number(), + }); + + const schema2 = z.change(schema1, { + email: z.string(), + }); + + // TypeScript should infer the correct type + type Schema2Type = z.infer; + const _typeTest: Schema2Type = { email: "test@example.com", age: 25 }; + expect(_typeTest).toBeDefined(); +}); + +test("change works with schema containing refinements using safeChange", () => { + const schema1 = z + .object({ + email: z.string(), + age: z.number(), + }) + .check(({ value }) => { + if (value.age < 0) throw new Error("Age must be non-negative"); + }); + + // change() should throw with refinements + expect(() => { + z.change(schema1, { email: z.string() }); + }).toThrow("Object schemas containing refinements cannot be changed. Use `.safeChange()` instead."); + + // safeChange() should work + const schema2 = z.safeChange(schema1, { + email: z.string(), + }); + + const result = schema2.parse({ email: "test@example.com", age: 25 }); + expect(result).toEqual({ email: "test@example.com", age: 25 }); +}); + +test("safeChange throws error when adding new properties", () => { + const schema1 = z.object({ + email: z.string(), + }); + + expect(() => { + z.safeChange(schema1, { + email: z.string(), + // @ts-expect-error + newProperty: z.string(), + }); + }).toThrow('Cannot change non-existing property: "newProperty"'); +}); + +test("safeChange enforces type compatibility at TypeScript level", () => { + const baseSchema = z.object({ + id: z.string(), + count: z.number(), + }); + + // This should work - compatible changes (same types) + const validChange = z.safeChange(baseSchema, { + id: z.string(), + count: z.number(), + }); + + expect(validChange).toBeDefined(); + + const result = validChange.parse({ id: "test", count: 42 }); + expect(result).toEqual({ id: "test", count: 42 }); +}); + +test("safeChange allows partial property changes", () => { + const baseSchema = z.object({ + id: z.string(), + count: z.number(), + active: z.boolean(), + }); + + // Should work - only changing one property with compatible type + const partialChange = z.safeChange(baseSchema, { + count: z.number(), + }); + + const result = partialChange.parse({ id: "test", count: 42, active: true }); + expect(result).toEqual({ id: "test", count: 42, active: true }); +}); + +test("safeChange runtime behavior is identical to change", () => { + const baseSchema = z.object({ + id: z.string(), + count: z.number(), + }); + + const changeResult = z.change(baseSchema, { + id: z.string(), + }); + + const safeChangeResult = z.safeChange(baseSchema, { + id: z.string(), + }); + + const testData = { id: "test", count: 42 }; + + // Runtime behavior should be identical + expect(changeResult.parse(testData)).toEqual(safeChangeResult.parse(testData)); + + // Both should handle the same valid data + expect(changeResult.parse(testData)).toEqual(testData); + expect(safeChangeResult.parse(testData)).toEqual(testData); +}); + +test("TypeScript prevents non-existing properties in change()", () => { + const user = z.object({ + id: z.string(), + name: z.string(), + }); + + // This should work - existing property + const validChange = z.change(user, { name: z.string() }); + expect(validChange).toBeDefined(); + + // TypeScript should prevent these at compile time - they would cause TS errors if uncommented: + // z.change(user, { nonExisting: z.string() }); + // z.safeChange(user, { nonExisting: z.string() }); + + // Test runtime error with explicit bypass of TypeScript for edge cases + expect(() => { + (z as any).change(user, { nonExisting: z.string() }); + }).toThrow('Cannot change non-existing property: "nonExisting"'); +});