From 083400aa20b57667d333cb51a33dbeb48ff62e7d Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 07:05:49 -0700 Subject: [PATCH 1/3] fix(typescript-zod): validate date and time formats --- .../src/language/TypeScriptZod/TypeScriptZodRenderer.ts | 8 +++++++- .../quicktype-core/src/language/TypeScriptZod/language.ts | 2 ++ test/inputs/schema/date-time.5.fail.date.json | 7 +++++++ test/inputs/schema/date-time.6.fail.time.json | 7 +++++++ test/languages.ts | 2 ++ 5 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 test/inputs/schema/date-time.5.fail.date.json create mode 100644 test/inputs/schema/date-time.6.fail.time.json diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index 140cbe28da..b7419b3948 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -200,8 +200,14 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { return ["z.union([", ...arrayIntercalate(", ", children), "])"]; }, (_transformedStringType) => { + if (_transformedStringType.kind === "date") { + return 'z.string().regex(/^\\d{4}-(?:0[1-9]|1[0-2])-(?:[0-2]\\d|3[01])$/).refine(value => (new Date(value + "T00:00:00Z").toJSON() || "").slice(0, 10) === value)'; + } + if (_transformedStringType.kind === "time") { + return "z.string().regex(/^(?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$/i)"; + } if (_transformedStringType.kind === "date-time") { - return "z.string().pipe(z.coerce.date())"; + return 'z.string().refine(value => z.string().datetime({ offset: true }).safeParse(value.toUpperCase()).success && (new Date(value.slice(0, 10) + "T00:00:00Z").toJSON() || "").slice(0, 10) === value.slice(0, 10)).pipe(z.coerce.date())'; } if (_transformedStringType.kind === "uuid") { return "z.string().uuid()"; diff --git a/packages/quicktype-core/src/language/TypeScriptZod/language.ts b/packages/quicktype-core/src/language/TypeScriptZod/language.ts index 77392f56f8..67793bb3e2 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/language.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/language.ts @@ -43,6 +43,8 @@ export class TypeScriptZodTargetLanguage extends TargetLanguage< const mapping: Map = new Map(); const dateTimeType = "date-time"; + mapping.set("date", "date"); + mapping.set("time", "time"); mapping.set("date-time", dateTimeType); mapping.set("uuid", "uuid"); mapping.set("bool-string", "bool-string"); diff --git a/test/inputs/schema/date-time.5.fail.date.json b/test/inputs/schema/date-time.5.fail.date.json new file mode 100644 index 0000000000..ffc8bfd914 --- /dev/null +++ b/test/inputs/schema/date-time.5.fail.date.json @@ -0,0 +1,7 @@ +{ + "date": "2023-02-29", + "time": "23:20:50.52Z", + "date-time": "2018-08-13T21:31:01+01:00", + "union-array": ["1985-04-12", "23:20:50.52Z"], + "complex-union-array": ["2018-08-13T21:31:01+00:10", "foo", 123] +} diff --git a/test/inputs/schema/date-time.6.fail.time.json b/test/inputs/schema/date-time.6.fail.time.json new file mode 100644 index 0000000000..6699c6c63e --- /dev/null +++ b/test/inputs/schema/date-time.6.fail.time.json @@ -0,0 +1,7 @@ +{ + "date": "1985-04-12", + "time": "29:99:99Z", + "date-time": "2018-08-13T21:31:01+01:00", + "union-array": ["1985-04-12", "23:20:50.52Z"], + "complex-union-array": ["2018-08-13T21:31:01+00:10", "foo", 123] +} diff --git a/test/languages.ts b/test/languages.ts index 8f534c31d2..ebfdb206ec 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1743,6 +1743,8 @@ export const TypeScriptZodLanguage: Language = { "union", "no-defaults", "date-time", + "date", + "time", "uuid", "bool-string", "integer-string", From 31267acb4d610b54908bdb29496c6b578e1069e8 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 18:51:13 -0700 Subject: [PATCH 2/3] fix(typescript-zod): reuse date and time schemas --- .../TypeScriptZod/TypeScriptZodRenderer.ts | 22 ++++++- .../typescript-zod-date-time-schemas.test.ts | 64 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 test/unit/typescript-zod-date-time-schemas.test.ts diff --git a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts index b7419b3948..f10abc01e8 100644 --- a/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptZod/TypeScriptZodRenderer.ts @@ -201,13 +201,13 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { }, (_transformedStringType) => { if (_transformedStringType.kind === "date") { - return 'z.string().regex(/^\\d{4}-(?:0[1-9]|1[0-2])-(?:[0-2]\\d|3[01])$/).refine(value => (new Date(value + "T00:00:00Z").toJSON() || "").slice(0, 10) === value)'; + return "dateSchema"; } if (_transformedStringType.kind === "time") { - return "z.string().regex(/^(?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$/i)"; + return "timeSchema"; } if (_transformedStringType.kind === "date-time") { - return 'z.string().refine(value => z.string().datetime({ offset: true }).safeParse(value.toUpperCase()).success && (new Date(value.slice(0, 10) + "T00:00:00Z").toJSON() || "").slice(0, 10) === value.slice(0, 10)).pipe(z.coerce.date())'; + return "dateTimeSchema"; } if (_transformedStringType.kind === "uuid") { return "z.string().uuid()"; @@ -465,6 +465,22 @@ export class TypeScriptZodRenderer extends ConvenienceRenderer { protected emitSchemas(): void { this.ensureBlankLine(); + const kinds = this.forEachType((type) => type.kind); + if (kinds.has("date")) { + this.emitLine( + 'const dateSchema = z.string().regex(/^\\d{4}-(?:0[1-9]|1[0-2])-(?:[0-2]\\d|3[01])$/).refine(value => (new Date(value + "T00:00:00Z").toJSON() || "").slice(0, 10) === value);', + ); + } + if (kinds.has("time")) { + this.emitLine( + "const timeSchema = z.string().regex(/^(?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$/i);", + ); + } + if (kinds.has("date-time")) { + this.emitLine( + 'const dateTimeSchema = z.string().refine(value => z.string().datetime({ offset: true }).safeParse(value.toUpperCase()).success && (new Date(value.slice(0, 10) + "T00:00:00Z").toJSON() || "").slice(0, 10) === value.slice(0, 10)).pipe(z.coerce.date());', + ); + } this.forEachEnum( "leading-and-interposing", diff --git a/test/unit/typescript-zod-date-time-schemas.test.ts b/test/unit/typescript-zod-date-time-schemas.test.ts new file mode 100644 index 0000000000..35269dd6d2 --- /dev/null +++ b/test/unit/typescript-zod-date-time-schemas.test.ts @@ -0,0 +1,64 @@ +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function render(properties: Record): Promise { + const input = new JSONSchemaInput(undefined); + await input.addSource({ + name: "TopLevel", + schema: JSON.stringify({ + type: "object", + properties, + required: Object.keys(properties), + }), + }); + const inputData = new InputData(); + inputData.addInput(input); + return (await quicktype({ inputData, lang: "typescript-zod" })).lines.join( + "\n", + ); +} + +describe("TypeScript Zod date and time schemas", () => { + test("emits each used format schema once", async () => { + const output = await render({ + firstDate: { type: "string", format: "date" }, + secondDate: { type: "string", format: "date" }, + firstTime: { type: "string", format: "time" }, + secondTime: { type: "string", format: "time" }, + firstDateTime: { type: "string", format: "date-time" }, + secondDateTime: { type: "string", format: "date-time" }, + }); + + expect(output.match(/const dateSchema =/g)).toHaveLength(1); + expect(output.match(/const timeSchema =/g)).toHaveLength(1); + expect(output.match(/const dateTimeSchema =/g)).toHaveLength(1); + expect(output.match(/: dateSchema/g)).toHaveLength(2); + expect(output.match(/: timeSchema/g)).toHaveLength(2); + expect(output.match(/: dateTimeSchema/g)).toHaveLength(2); + }); + + test.each([ + "date", + "time", + "date-time", + ])("emits only the %s format schema", async (format) => { + const output = await render({ + value: { type: "string", format }, + }); + + for (const [schema, schemaFormat] of [ + ["dateSchema", "date"], + ["timeSchema", "time"], + ["dateTimeSchema", "date-time"], + ]) { + expect(output.includes(`const ${schema} =`)).toBe( + schemaFormat === format, + ); + } + }); + + test("omits format schemas for plain strings", async () => { + const output = await render({ value: { type: "string" } }); + expect(output).not.toMatch(/const (?:date|time|dateTime)Schema =/); + }); +}); From 3f7c3ccd5bcec12a123a94cf9aa92434adffb094 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 20:03:25 -0700 Subject: [PATCH 3/3] test: remove generated-output assertions --- .agents/skills/enable-small-tests/SKILL.md | 2 +- .../typescript-zod-date-time-schemas.test.ts | 64 ------------------- 2 files changed, 1 insertion(+), 65 deletions(-) delete mode 100644 test/unit/typescript-zod-date-time-schemas.test.ts diff --git a/.agents/skills/enable-small-tests/SKILL.md b/.agents/skills/enable-small-tests/SKILL.md index 294e0b629d..7fba0c25fc 100644 --- a/.agents/skills/enable-small-tests/SKILL.md +++ b/.agents/skills/enable-small-tests/SKILL.md @@ -17,7 +17,7 @@ Turn disabled coverage into small, independently reviewable fixes. Read the repo - **Use shared test cases, not new per-bug fixtures.** General inputs, especially keyword cases, must run across languages. A newly discovered bug does not by itself justify a new driver, fixture registration, schema, or language-only input list. - Preserve intended generated naming. Reject “stabilize names” changes whose only purpose is making JSON-versus-schema output text identical. Renaming is justified when the original name actually prevents compilation or runtime behavior, with evidence. - Modify only relevant code; respect existing abstraction boundaries. No unrelated cleanup, version bumps, or tombstone comments/docs explaining removed behavior. -- Emit new generated functions, definitions, and imports only when the input types or renderer options require them. Validate generated output both with and without that requirement. +- Emit new generated functions, definitions, and imports only when the input types or renderer options require them. Review the generated-output diff for unnecessary helpers, codecs, imports, or renamed types; do not add unit tests solely to assert helper or import presence or absence, or codec definition counts. Keep unit tests for API behavior and meaningful public naming regressions that fixtures cannot express. - Write descriptions, comments, commits, and summaries tersely. Explain **what and why**, include useful links and validation, and omit praise and conversational history. ## Find candidates diff --git a/test/unit/typescript-zod-date-time-schemas.test.ts b/test/unit/typescript-zod-date-time-schemas.test.ts deleted file mode 100644 index 35269dd6d2..0000000000 --- a/test/unit/typescript-zod-date-time-schemas.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; -import { describe, expect, test } from "vitest"; - -async function render(properties: Record): Promise { - const input = new JSONSchemaInput(undefined); - await input.addSource({ - name: "TopLevel", - schema: JSON.stringify({ - type: "object", - properties, - required: Object.keys(properties), - }), - }); - const inputData = new InputData(); - inputData.addInput(input); - return (await quicktype({ inputData, lang: "typescript-zod" })).lines.join( - "\n", - ); -} - -describe("TypeScript Zod date and time schemas", () => { - test("emits each used format schema once", async () => { - const output = await render({ - firstDate: { type: "string", format: "date" }, - secondDate: { type: "string", format: "date" }, - firstTime: { type: "string", format: "time" }, - secondTime: { type: "string", format: "time" }, - firstDateTime: { type: "string", format: "date-time" }, - secondDateTime: { type: "string", format: "date-time" }, - }); - - expect(output.match(/const dateSchema =/g)).toHaveLength(1); - expect(output.match(/const timeSchema =/g)).toHaveLength(1); - expect(output.match(/const dateTimeSchema =/g)).toHaveLength(1); - expect(output.match(/: dateSchema/g)).toHaveLength(2); - expect(output.match(/: timeSchema/g)).toHaveLength(2); - expect(output.match(/: dateTimeSchema/g)).toHaveLength(2); - }); - - test.each([ - "date", - "time", - "date-time", - ])("emits only the %s format schema", async (format) => { - const output = await render({ - value: { type: "string", format }, - }); - - for (const [schema, schemaFormat] of [ - ["dateSchema", "date"], - ["timeSchema", "time"], - ["dateTimeSchema", "date-time"], - ]) { - expect(output.includes(`const ${schema} =`)).toBe( - schemaFormat === format, - ); - } - }); - - test("omits format schemas for plain strings", async () => { - const output = await render({ value: { type: "string" } }); - expect(output).not.toMatch(/const (?:date|time|dateTime)Schema =/); - }); -});