From 3186adf68f09c9d1711bd7a80b11fc0c2547993d Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Thu, 14 Aug 2025 11:32:55 +0900 Subject: [PATCH 1/2] fix: allow record parsing with non-function constructor field --- .../v4/core/tests/record-constructor.test.ts | 67 +++++++++++++++++++ packages/zod/src/v4/core/util.ts | 2 + 2 files changed, 69 insertions(+) create mode 100644 packages/zod/src/v4/core/tests/record-constructor.test.ts diff --git a/packages/zod/src/v4/core/tests/record-constructor.test.ts b/packages/zod/src/v4/core/tests/record-constructor.test.ts new file mode 100644 index 0000000000..66f8af3d97 --- /dev/null +++ b/packages/zod/src/v4/core/tests/record-constructor.test.ts @@ -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" }); +}); diff --git a/packages/zod/src/v4/core/util.ts b/packages/zod/src/v4/core/util.ts index c1169d937a..2ced4e7a81 100644 --- a/packages/zod/src/v4/core/util.ts +++ b/packages/zod/src/v4/core/util.ts @@ -377,6 +377,8 @@ export function isPlainObject(o: any): o is Record { const ctor = o.constructor; if (ctor === undefined) return true; + if (typeof ctor !== "function") return true; + // modified prototype const prot = ctor.prototype; if (isObject(prot) === false) return false; From cb134b9caa52d9239ca56bb27a25f7c900652457 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Thu, 14 Aug 2025 11:36:10 +0900 Subject: [PATCH 2/2] test: add test cases for handling constructor field in object schemas --- .../zod/src/v4/classic/tests/index.test.ts | 31 +++++++++++++ packages/zod/src/v4/core/tests/extend.test.ts | 43 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/zod/src/v4/classic/tests/index.test.ts b/packages/zod/src/v4/classic/tests/index.test.ts index 7fa6f20fc8..3f8a5c5ea7 100644 --- a/packages/zod/src/v4/classic/tests/index.test.ts +++ b/packages/zod/src/v4/classic/tests/index.test.ts @@ -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", () => { diff --git a/packages/zod/src/v4/core/tests/extend.test.ts b/packages/zod/src/v4/core/tests/extend.test.ts index 101adac1e6..ce50e05388 100644 --- a/packages/zod/src/v4/core/tests/extend.test.ts +++ b/packages/zod/src/v4/core/tests/extend.test.ts @@ -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", () => { @@ -16,3 +16,44 @@ test("extend chaining preserves and overrides properties", () => { schema3.parse({ email: "test@example.com" }); }); + +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); + } +});