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
31 changes: 31 additions & 0 deletions packages/zod/src/v4/classic/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,37 @@ test("isPlainObject", () => {
expect(z.core.util.isPlainObject("string")).toEqual(false);
expect(z.core.util.isPlainObject(123)).toEqual(false);
expect(z.core.util.isPlainObject(Symbol())).toEqual(false);
expect(z.core.util.isPlainObject({ constructor: "string" })).toEqual(true);
expect(z.core.util.isPlainObject({ constructor: 123 })).toEqual(true);
expect(z.core.util.isPlainObject({ constructor: null })).toEqual(true);
expect(z.core.util.isPlainObject({ constructor: undefined })).toEqual(true);
expect(z.core.util.isPlainObject({ constructor: true })).toEqual(true);
expect(z.core.util.isPlainObject({ constructor: {} })).toEqual(true);
expect(z.core.util.isPlainObject({ constructor: [] })).toEqual(true);
});

test("shallowClone with constructor field", () => {
const objWithConstructor = { constructor: "string", key: "value" };
const cloned = z.core.util.shallowClone(objWithConstructor);

expect(cloned).toEqual(objWithConstructor);
expect(cloned).not.toBe(objWithConstructor);
expect(cloned.constructor).toBe("string");
expect(cloned.key).toBe("value");

const testCases = [
{ constructor: 123, data: "test" },
{ constructor: null, data: "test" },
{ constructor: true, data: "test" },
{ constructor: {}, data: "test" },
{ constructor: [], data: "test" },
];

for (const testCase of testCases) {
const clonedCase = z.core.util.shallowClone(testCase);
expect(clonedCase).toEqual(testCase);
expect(clonedCase).not.toBe(testCase);
}
});

test("def typing", () => {
Expand Down
43 changes: 42 additions & 1 deletion packages/zod/src/v4/core/tests/extend.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from "vitest";
import { expect, test } from "vitest";
import * as z from "zod/v4";

test("extend chaining preserves and overrides properties", () => {
Expand All @@ -16,3 +16,44 @@ test("extend chaining preserves and overrides properties", () => {

schema3.parse({ email: "[email protected]" });
});

test("extend with constructor field in shape", () => {
const baseSchema = z.object({
name: z.string(),
});

const extendedSchema = baseSchema.extend({
constructor: z.string(),
age: z.number(),
});

const result = extendedSchema.parse({
name: "John",
constructor: "Person",
age: 30,
});

expect(result).toEqual({
name: "John",
constructor: "Person",
age: 30,
});

const testCases = [
{ name: "Test", constructor: 123, age: 25 },
{ name: "Test", constructor: null, age: 25 },
{ name: "Test", constructor: true, age: 25 },
{ name: "Test", constructor: {}, age: 25 },
];

for (const testCase of testCases) {
const anyConstructorSchema = baseSchema.extend({
constructor: z.any(),
age: z.number(),
});

expect(() => anyConstructorSchema.parse(testCase)).not.toThrow();
const parsed = anyConstructorSchema.parse(testCase);
expect(parsed).toEqual(testCase);
}
});
67 changes: 67 additions & 0 deletions packages/zod/src/v4/core/tests/record-constructor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test } from "vitest";
import * as z from "zod/v4";

test("record should parse objects with non-function constructor field", () => {
const schema = z.record(z.string(), z.any());

expect(() => schema.parse({ constructor: "string", key: "value" })).not.toThrow();

const result1 = schema.parse({ constructor: "string", key: "value" });
expect(result1).toEqual({ constructor: "string", key: "value" });

expect(() => schema.parse({ constructor: 123, key: "value" })).not.toThrow();

const result2 = schema.parse({ constructor: 123, key: "value" });
expect(result2).toEqual({ constructor: 123, key: "value" });

expect(() => schema.parse({ constructor: null, key: "value" })).not.toThrow();

const result3 = schema.parse({ constructor: null, key: "value" });
expect(result3).toEqual({ constructor: null, key: "value" });

expect(() => schema.parse({ constructor: {}, key: "value" })).not.toThrow();

const result4 = schema.parse({ constructor: {}, key: "value" });
expect(result4).toEqual({ constructor: {}, key: "value" });

expect(() => schema.parse({ constructor: [], key: "value" })).not.toThrow();

const result5 = schema.parse({ constructor: [], key: "value" });
expect(result5).toEqual({ constructor: [], key: "value" });

expect(() => schema.parse({ constructor: true, key: "value" })).not.toThrow();

const result6 = schema.parse({ constructor: true, key: "value" });
expect(result6).toEqual({ constructor: true, key: "value" });
});

test("record should still work with normal objects", () => {
const schema = z.record(z.string(), z.string());

expect(() => schema.parse({ normalKey: "value" })).not.toThrow();

const result1 = schema.parse({ normalKey: "value" });
expect(result1).toEqual({ normalKey: "value" });

expect(() => schema.parse({ key1: "value1", key2: "value2" })).not.toThrow();

const result2 = schema.parse({ key1: "value1", key2: "value2" });
expect(result2).toEqual({ key1: "value1", key2: "value2" });
});

test("record should validate values according to schema even with constructor field", () => {
const stringSchema = z.record(z.string(), z.string());

expect(() => stringSchema.parse({ constructor: "string", key: "value" })).not.toThrow();

expect(() => stringSchema.parse({ constructor: 123, key: "value" })).toThrow();
});

test("record should work with different key types and constructor field", () => {
const enumSchema = z.record(z.enum(["constructor", "key"]), z.string());

expect(() => enumSchema.parse({ constructor: "value1", key: "value2" })).not.toThrow();

const result = enumSchema.parse({ constructor: "value1", key: "value2" });
expect(result).toEqual({ constructor: "value1", key: "value2" });
});
2 changes: 2 additions & 0 deletions packages/zod/src/v4/core/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ export function isPlainObject(o: any): o is Record<PropertyKey, unknown> {
const ctor = o.constructor;
if (ctor === undefined) return true;

if (typeof ctor !== "function") return true;
Copy link

@adam-coster adam-coster Aug 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 380 could replace line 378, instead of having both, since this check includes the case where ctor === undefined (since in that case typeof ctor is "undefined", which does not equal "function"). But the extra clarity doesn't hurt anything.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're suggestion is technically correct - line 380 (typeof ctor !== "function") would handle both cases since typeof undefined returns "undefined", not "function".
However, I'd keep both lines for clarity. The explicit undefined check makes the intent more obvious and doesn't hurt performance.
It's a style preference, and the current approach is more readable.


// modified prototype
const prot = ctor.prototype;
if (isObject(prot) === false) return false;
Expand Down