Skip to content

Commit 87cf0f9

Browse files
committed
fix(fromJSONSchema): normalize input via JSON round-trip
`fromJSONSchema` previously stack-overflowed on cyclic JS object inputs (e.g. dereferenced JSON Schemas where `$ref` strings have been replaced with live object references) and on inputs with infinite-loop-on-access getters or Proxies. JSON Schema is JSON, and JSON has no cycles — these inputs are off-spec. Round-trip the input through `JSON.parse(JSON.stringify(...))` at the top of `fromJSONSchema`. Cyclic and otherwise non-JSON inputs throw a clear error. Getter/Proxy properties are materialized into static values and class instances collapse to plain objects, so the rest of the converter only ever walks a finite, plain object graph. Closes #5675.
1 parent c7a8ccc commit 87cf0f9

2 files changed

Lines changed: 69 additions & 4 deletions

File tree

packages/zod/src/v4/classic/from-json-schema.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -632,17 +632,28 @@ export function fromJSONSchema(schema: JSONSchema.JSONSchema | boolean, params?:
632632
return schema ? z.any() : z.never();
633633
}
634634

635-
const version = detectVersion(schema, params?.defaultTarget);
636-
const defs = (schema.$defs || schema.definitions || {}) as Record<string, JSONSchema.JSONSchema>;
635+
// Normalize input via a JSON round-trip. This guarantees the converter
636+
// walks a plain, finite, JSON-valid object graph: cyclic inputs fail here,
637+
// getter/Proxy-based properties are materialized into static values, and
638+
// class instances collapse to plain objects.
639+
let normalized: JSONSchema.JSONSchema;
640+
try {
641+
normalized = JSON.parse(JSON.stringify(schema));
642+
} catch {
643+
throw new Error("fromJSONSchema input is not valid JSON (possibly cyclic); use $defs/$ref for recursive schemas");
644+
}
645+
646+
const version = detectVersion(normalized, params?.defaultTarget);
647+
const defs = (normalized.$defs || normalized.definitions || {}) as Record<string, JSONSchema.JSONSchema>;
637648

638649
const ctx: ConversionContext = {
639650
version,
640651
defs,
641652
refs: new Map(),
642653
processing: new Set(),
643-
rootSchema: schema,
654+
rootSchema: normalized,
644655
registry: params?.registry ?? globalRegistry,
645656
};
646657

647-
return convertSchema(schema, ctx);
658+
return convertSchema(normalized, ctx);
648659
}

packages/zod/src/v4/classic/tests/from-json-schema.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,57 @@ test("description and unrecognized metadata coexist on the same schema", () => {
839839
expect(schema.description).toBe("A custom string");
840840
expect(customRegistry.get(schema)?.["x-custom"]).toBe("value");
841841
});
842+
843+
test("circular input throws a clear error", () => {
844+
const person: any = {
845+
type: "object",
846+
properties: { name: { type: "string" } },
847+
required: ["name"],
848+
};
849+
person.properties.bestFriend = person;
850+
expect(() => fromJSONSchema(person)).toThrow(/not valid JSON/);
851+
});
852+
853+
test("getter-based input that synthesizes a cycle throws", () => {
854+
const root: any = { type: "object", properties: { name: { type: "string" } } };
855+
Object.defineProperty(root.properties, "self", {
856+
enumerable: true,
857+
get() {
858+
return root;
859+
},
860+
});
861+
expect(() => fromJSONSchema(root)).toThrow(/not valid JSON/);
862+
});
863+
864+
test("BigInt in input throws", () => {
865+
const input: any = { type: "integer", minimum: 1n };
866+
expect(() => fromJSONSchema(input)).toThrow(/not valid JSON/);
867+
});
868+
869+
test("class-instance input is normalized to a plain object", () => {
870+
class StringSchema {
871+
type = "string" as const;
872+
minLength = 2;
873+
}
874+
const schema = fromJSONSchema(new StringSchema() as any);
875+
expect(schema.parse("hi")).toBe("hi");
876+
expect(() => schema.parse("h")).toThrow();
877+
});
878+
879+
test("getter-based properties are materialized", () => {
880+
const input: any = { type: "object", properties: {}, required: [] };
881+
Object.defineProperty(input.properties, "name", {
882+
enumerable: true,
883+
get() {
884+
return { type: "string" };
885+
},
886+
});
887+
const schema = fromJSONSchema(input);
888+
expect(schema.parse({ name: "Alice" })).toEqual({ name: "Alice" });
889+
});
890+
891+
test("Date default is coerced to its JSON string form", () => {
892+
const date = new Date("2026-01-02T03:04:05.000Z");
893+
const schema = fromJSONSchema({ type: "string", default: date as any });
894+
expect(schema.parse(undefined)).toBe(date.toISOString());
895+
});

0 commit comments

Comments
 (0)