Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion packages/docs/content/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1269,8 +1269,64 @@ const Extended = z.safeExtend(UserSchema, {
</Tab>
</Tabs>

### `.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
```

<Tabs groupId="lib" items={["Zod", "Zod Mini"]}>
<Tab value="Zod">
```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)
});
```
</Tab>
<Tab value="Zod Mini">
```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)
});
```
</Tab>
</Tabs>

### `.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:
Expand Down
44 changes: 34 additions & 10 deletions packages/zod/src/v4/classic/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ export const ZodType: core.$constructor<ZodType> = /*@__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
),
],
}
Expand Down Expand Up @@ -1113,6 +1117,16 @@ export type SafeExtendShape<Base extends core.$ZodShape, Ext extends core.$ZodLo
: Ext[K];
};

export type SafeChangeShape<Base extends core.$ZodShape, Changes extends core.$ZodLooseShape> = {
[K in keyof Changes]: K extends keyof Base
? core.output<Changes[K]> extends core.output<Base[K]>
? core.input<Changes[K]> extends core.input<Base[K]>
? 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,
Expand Down Expand Up @@ -1142,6 +1156,12 @@ export interface ZodObject<
shape: SafeExtendShape<Shape, U> & Partial<Record<keyof Shape, core.SomeType>>
): ZodObject<util.Extend<Shape, U>, Config>;

change<U extends Partial<Record<keyof Shape, core.SomeType>>>(shape: U): ZodObject<util.Extend<Shape, U>, Config>;

safeChange<U extends Partial<Record<keyof Shape, core.SomeType>>>(
shape: SafeChangeShape<Shape, U> & Partial<Record<keyof Shape, core.SomeType>>
): ZodObject<util.Extend<Shape, U>, Config>;

/**
* @deprecated Use [`A.extend(B.shape)`](https://zod.dev/api?id=extend) instead.
*/
Expand Down Expand Up @@ -1198,7 +1218,11 @@ export const ZodObject: core.$constructor<ZodObject> = /*@__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() });
Expand All @@ -1210,6 +1234,12 @@ export const ZodObject: core.$constructor<ZodObject> = /*@__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);
Expand Down Expand Up @@ -2065,10 +2095,7 @@ export function _function<const In extends Array<core.$ZodType> = Array<core.$Zo
export function _function<
const In extends Array<core.$ZodType> = Array<core.$ZodType>,
const Out extends core.$ZodFunctionOut = core.$ZodFunctionOut,
>(params: {
input: In;
output: Out;
}): ZodFunction<ZodTuple<In, null>, Out>;
>(params: { input: In; output: Out }): ZodFunction<ZodTuple<In, null>, Out>;
export function _function<const In extends core.$ZodFunctionIn = core.$ZodFunctionIn>(params: {
input: In;
}): ZodFunction<In, core.$ZodFunctionOut>;
Expand All @@ -2078,10 +2105,7 @@ export function _function<const Out extends core.$ZodFunctionOut = core.$ZodFunc
export function _function<
In extends core.$ZodFunctionIn = core.$ZodFunctionIn,
Out extends core.$ZodType = core.$ZodType,
>(params?: {
input: In;
output: Out;
}): ZodFunction<In, Out>;
>(params?: { input: In; output: Out }): ZodFunction<In, Out>;
export function _function(params?: {
output?: core.$ZodType;
input?: core.$ZodFunctionArgs | Array<core.$ZodType>;
Expand Down
156 changes: 156 additions & 0 deletions packages/zod/src/v4/core/tests/change.test.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]", age: 25 });
expect(result).toEqual({ email: "[email protected]", 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: "[email protected]", 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: "[email protected]",
age: 25,
name: "John",
});
expect(result).toEqual({ email: "[email protected]", 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<Record<"email", SomeType>>'
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<typeof schema2>;
const _typeTest: Schema2Type = { email: "[email protected]", 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: "[email protected]", age: 25 });
expect(result).toEqual({ email: "[email protected]", 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: "[email protected]", age: 25 });

// Should accept empty string email
schema3.parse({ email: "", age: 25 });

// Should reject invalid age
expect(() => schema3.parse({ email: "", age: 16 })).toThrow();
});
61 changes: 60 additions & 1 deletion packages/zod/src/v4/core/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,9 @@ export function omit(schema: schemas.$ZodObject, mask: object): any {

const def = mergeDefs(schema._zod.def, {
get shape() {
const newShape: Writeable<schemas.$ZodShape> = { ...schema._zod.def.shape };
const newShape: Writeable<schemas.$ZodShape> = {
...schema._zod.def.shape,
};
for (const key in mask) {
if (!(key in currDef.shape)) {
throw new Error(`Unrecognized key: "${key}"`);
Expand Down Expand Up @@ -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() {
Expand Down
Loading