Skip to content

Commit f8babdf

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
fix(schema-to-json): support Zod 4 date schemas
Fix Zod 4 date conversion in JSON Schema generation while preserving errors for other unsupported schema types. Fixes [#4939](#4939). Mono-RevId: eac25ac271420ef81afc6d49bcd2df69f8116b18
1 parent 62157b1 commit f8babdf

4 files changed

Lines changed: 178 additions & 52 deletions

File tree

‎.changeset/calm-dates-listen.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/schema-to-json": patch
3+
---
4+
5+
Convert Zod 4 `z.date()` fields to date-time strings in JSON Schema without weakening validation for other unsupported types. This prevents MCP tool discovery from failing when a tool input schema contains a date.

‎packages/schema-to-json/src/index.ts‎

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Import JSONSchema from core to ensure compatibility
22
import type { JSONSchema } from "@trigger.dev/core/v3";
33
import { zodToJsonSchema } from "zod-to-json-schema";
4-
import * as z4 from "zod/v4/core";
54
import { convertSchema } from "@sodaru/yup-to-json-schema";
65
import { JSONSchema as EffectJSONSchema } from "effect";
6+
import { convertZod4Schema } from "./zod4.js";
77

88
export type Schema = unknown;
99
export type { JSONSchema };
@@ -149,57 +149,6 @@ function convertZod3Schema(schema: any, options?: ConversionOptions): JSONSchema
149149
}) as JSONSchema;
150150
}
151151

152-
function convertZod4Schema(schema: any, options?: ConversionOptions): JSONSchema | undefined {
153-
const useReferences = options?.useReferences ?? false;
154-
155-
return z4.toJSONSchema(schema, {
156-
target: "draft-7",
157-
io: "output",
158-
reused: useReferences ? "ref" : "inline",
159-
override: ({ zodSchema, jsonSchema }) => {
160-
const def = zodSchema._zod.def;
161-
if (def.type === "undefined") {
162-
throw new Error("Undefined cannot be represented in JSON Schema");
163-
}
164-
165-
if (def.type === "object" && jsonSchema.required) {
166-
// Early Zod 4 permalinks do not propagate optional output through unions.
167-
const required = jsonSchema.required.filter((key) => {
168-
const field = def.shape[key];
169-
return !field || field._zod.optout === "optional" || !hasOptionalOutput(field);
170-
});
171-
if (required.length > 0) {
172-
jsonSchema.required = required;
173-
} else {
174-
delete jsonSchema.required;
175-
}
176-
}
177-
},
178-
}) as JSONSchema;
179-
}
180-
181-
function hasOptionalOutput(schema: z4.$ZodType, seen = new Set<z4.$ZodType>()): boolean {
182-
if (schema._zod.optout === "optional") return true;
183-
if (seen.has(schema)) return false;
184-
seen.add(schema);
185-
186-
const def = (schema as z4.$ZodTypes)._zod.def;
187-
switch (def.type) {
188-
case "union":
189-
return def.options.some((option) => hasOptionalOutput(option, seen));
190-
case "nullable":
191-
case "readonly":
192-
case "catch":
193-
return hasOptionalOutput(def.innerType, seen);
194-
case "lazy":
195-
return hasOptionalOutput(def.getter(), seen);
196-
case "pipe":
197-
return hasOptionalOutput(def.out, seen);
198-
default:
199-
return false;
200-
}
201-
}
202-
203152
function isYupSchema(schema: any): boolean {
204153
return "spec" in schema && "_typeCheck" in schema;
205154
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { JSONSchema } from "@trigger.dev/core/v3";
2+
import * as z4 from "zod/v4/core";
3+
import type { ConversionOptions } from "./index.js";
4+
5+
type Zod4Converter = {
6+
version: { major: number; minor: number };
7+
toJSONSchema: (schema: any, options: any) => unknown;
8+
};
9+
10+
export function convertZod4Schema(
11+
schema: any,
12+
options?: ConversionOptions,
13+
converter: Zod4Converter = z4
14+
): JSONSchema | undefined {
15+
const useReferences = options?.useReferences ?? false;
16+
const supportsUnrepresentableHandler =
17+
converter.version.major > 4 || (converter.version.major === 4 && converter.version.minor >= 5);
18+
19+
return converter.toJSONSchema(schema, {
20+
target: "draft-7",
21+
io: "output",
22+
reused: useReferences ? "ref" : "inline",
23+
unrepresentable: supportsUnrepresentableHandler
24+
? ({ zodSchema }: { zodSchema: z4.$ZodTypes }) =>
25+
zodSchema._zod.def.type === "date" ? { type: "string", format: "date-time" } : "throw"
26+
: "any",
27+
override: ({ zodSchema, jsonSchema }: { zodSchema: z4.$ZodTypes; jsonSchema: any }) => {
28+
const def = zodSchema._zod.def;
29+
30+
if (!supportsUnrepresentableHandler) {
31+
enforceLegacyUnrepresentableTypes(def, jsonSchema);
32+
}
33+
34+
if (def.type === "date") {
35+
jsonSchema.type = "string";
36+
jsonSchema.format = "date-time";
37+
}
38+
39+
if (def.type === "undefined") {
40+
throw new Error("Undefined cannot be represented in JSON Schema");
41+
}
42+
43+
if (def.type === "object" && jsonSchema.required) {
44+
const required = jsonSchema.required.filter((key: string) => {
45+
const field = def.shape[key];
46+
return !field || field._zod.optout === "optional" || !hasOptionalOutput(field);
47+
});
48+
if (required.length > 0) {
49+
jsonSchema.required = required;
50+
} else {
51+
delete jsonSchema.required;
52+
}
53+
}
54+
},
55+
}) as JSONSchema;
56+
}
57+
58+
function enforceLegacyUnrepresentableTypes(def: any, jsonSchema: Record<string, unknown>) {
59+
switch (def.type) {
60+
case "bigint":
61+
throw new Error("BigInt cannot be represented in JSON Schema");
62+
case "symbol":
63+
throw new Error("Symbols cannot be represented in JSON Schema");
64+
case "void":
65+
throw new Error("Void cannot be represented in JSON Schema");
66+
case "map":
67+
throw new Error("Map cannot be represented in JSON Schema");
68+
case "set":
69+
throw new Error("Set cannot be represented in JSON Schema");
70+
case "transform":
71+
throw new Error("Transforms cannot be represented in JSON Schema");
72+
case "nan":
73+
throw new Error("NaN cannot be represented in JSON Schema");
74+
case "custom":
75+
throw new Error("Custom types cannot be represented in JSON Schema");
76+
case "function":
77+
throw new Error("Function types cannot be represented in JSON Schema");
78+
case "literal": {
79+
if (def.values.some((value: unknown) => value === undefined)) {
80+
throw new Error("Literal `undefined` cannot be represented in JSON Schema");
81+
}
82+
if (def.values.some((value: unknown) => typeof value === "bigint")) {
83+
throw new Error("BigInt literals cannot be represented in JSON Schema");
84+
}
85+
return;
86+
}
87+
case "catch": {
88+
if ("default" in jsonSchema) return;
89+
try {
90+
def.catchValue(undefined);
91+
} catch {
92+
throw new Error("Dynamic catch values are not supported in JSON Schema");
93+
}
94+
}
95+
}
96+
}
97+
98+
function hasOptionalOutput(schema: z4.$ZodType, seen = new Set<z4.$ZodType>()): boolean {
99+
if (schema._zod.optout === "optional") return true;
100+
if (seen.has(schema)) return false;
101+
seen.add(schema);
102+
103+
const def = (schema as z4.$ZodTypes)._zod.def;
104+
switch (def.type) {
105+
case "union":
106+
return def.options.some((option) => hasOptionalOutput(option, seen));
107+
case "nullable":
108+
case "readonly":
109+
case "catch":
110+
return hasOptionalOutput(def.innerType, seen);
111+
case "lazy":
112+
return hasOptionalOutput(def.getter(), seen);
113+
case "pipe":
114+
return hasOptionalOutput(def.out, seen);
115+
default:
116+
return false;
117+
}
118+
}

‎packages/schema-to-json/tests/index.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as z3 from "zod/v3";
22
import * as z4 from "zod/v4";
33
import * as zFloor from "zod-v3-floor/v4";
4+
import * as zFloorCore from "zod-v3-floor/v4/core";
45
import * as y from "yup";
56
// @ts-ignore
67
import { type } from "arktype";
78
import { Schema } from "effect";
89
import { Type } from "@sinclair/typebox";
910
import { schemaToJsonSchema, canConvertSchema } from "../src/index.js";
11+
import { convertZod4Schema } from "../src/zod4.js";
1012

1113
describe("schemaToJsonSchema", () => {
1214
describe("Zod schemas", () => {
@@ -110,6 +112,58 @@ describe("schemaToJsonSchema", () => {
110112
"Undefined cannot be represented in JSON Schema"
111113
);
112114
});
115+
116+
it("converts dates to date-time strings", () => {
117+
const schema = z.object({
118+
createdAt: z.date(),
119+
updatedAt: z.date().optional(),
120+
deletedAt: z.date().nullable(),
121+
history: z.array(z.date()),
122+
});
123+
124+
expect(schemaToJsonSchema(schema)?.jsonSchema).toMatchObject({
125+
type: "object",
126+
properties: {
127+
createdAt: { type: "string", format: "date-time" },
128+
updatedAt: { type: "string", format: "date-time" },
129+
deletedAt: {
130+
anyOf: [{ type: "string", format: "date-time" }, { type: "null" }],
131+
},
132+
history: {
133+
type: "array",
134+
items: { type: "string", format: "date-time" },
135+
},
136+
},
137+
required: ["createdAt", "deletedAt", "history"],
138+
});
139+
});
140+
});
141+
142+
describe("legacy Zod 4 JSON Schema conversion", () => {
143+
const convert = (schema: zFloor.ZodType) => convertZod4Schema(schema, undefined, zFloorCore);
144+
145+
it("converts dates without relaxing other unrepresentable types", () => {
146+
expect(convert(zFloor.object({ when: zFloor.date() }))).toMatchObject({
147+
type: "object",
148+
properties: {
149+
when: { type: "string", format: "date-time" },
150+
},
151+
required: ["when"],
152+
});
153+
});
154+
155+
it("continues to reject unrepresentable types", () => {
156+
expect(() => convert(zFloor.bigint())).toThrow("BigInt cannot be represented in JSON Schema");
157+
expect(() => convert(zFloor.symbol())).toThrow(
158+
"Symbols cannot be represented in JSON Schema"
159+
);
160+
expect(() => convert(zFloor.transform((value) => value))).toThrow(
161+
"Transforms cannot be represented in JSON Schema"
162+
);
163+
expect(() => convert(zFloor.literal(undefined))).toThrow(
164+
"Literal `undefined` cannot be represented in JSON Schema"
165+
);
166+
});
113167
});
114168

115169
it("preserves explicitly required metadata on current optional schemas", () => {

0 commit comments

Comments
 (0)