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"');
+});