From 54d577a3a1fe6c66d47bec67dc862e1eb09d61df Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 7 Nov 2026 19:29:44 +0800 Subject: [PATCH 1/4] fix: import the type an x-typescript-hint names into valibot.ts A hint that names a generated type, such as `EmbedUrl`, reached valibot.ts as `v.custom` with no import, so the module did not compile. The validators module now imports every type it references that the types module exports, which also covers the annotations on lazy refs. Co-Authored-By: LLM --- .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- __tests__/fixtures/test1.json | 15 ++++++ .../test1/.openapi-codegen-manifest.json | 6 +-- __tests__/fixtures/test1/types.ts | 4 ++ __tests__/fixtures/test1/valibot.ts | 27 +++++++++++ __tests__/typescript-hint.test.ts | 37 +++++++++++++++ lib/process-document.ts | 6 +-- lib/valibot.ts | 46 +++++++++++-------- 10 files changed, 118 insertions(+), 29 deletions(-) diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 82c3e30..08d7ca4 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "5c0951f8416ec95d05f33ca493ed6922", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 7cfb4e6..06dfbaa 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "5c0951f8416ec95d05f33ca493ed6922", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index a3412d7..ef85a11 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "5c0951f8416ec95d05f33ca493ed6922", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1.json b/__tests__/fixtures/test1.json index fa71c40..600c3bd 100644 --- a/__tests__/fixtures/test1.json +++ b/__tests__/fixtures/test1.json @@ -91,6 +91,21 @@ "pattern": "^https://embed\\.example\\.com/.+$", "x-typescript-hint": "`https://embed.example.com/${string}`" }, + "EmbedLink": { + "type": "object", + "additionalProperties": false, + "required": ["url"], + "properties": { + "url": { + "type": "string", + "x-typescript-hint": "EmbedUrl" + }, + "label": { + "type": "string", + "x-typescript-hint": "Capitalize" + } + } + }, "EventSource": { "oneOf": [ { diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 8e06187..ca672b2 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "8e34237f9aac0959082f1a4be6dfe7dd", + "#generator": "5c0951f8416ec95d05f33ca493ed6922", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", - "types.ts": "7bb3f162d3321e4db51673d3f3ba35ac", + "types.ts": "9e314cc81e615676b0b6e8a0af8d330f", "main.ts": "5c768be2e48d0b06d7ed8da665653404", - "valibot.ts": "c8e831e3378ad09f85be0d368e1f0cf2", + "valibot.ts": "20a8711d7b15c3074d7d21d33cb4a0f7", "hono.ts": "12cc7ef64bfc4acf632ba9d9011880d8", "commands-validated.ts": "afd10b6462e83dbe33e5d42dd227cd97", "enums.ts": "26ade5ccad3bf30118ce8691b99b7c91" diff --git a/__tests__/fixtures/test1/types.ts b/__tests__/fixtures/test1/types.ts index 377c0e7..84291c7 100644 --- a/__tests__/fixtures/test1/types.ts +++ b/__tests__/fixtures/test1/types.ts @@ -19,6 +19,10 @@ export type NullableDiscount = number | null; export type NullableStatus = "active" | "paused" | "cancelled" | null; export type AccountTier = "free" | "pro" | "enterprise"; export type EmbedUrl = `https://embed.example.com/${string}`; +export type EmbedLink = { + url: EmbedUrl; + label?: Capitalize; +}; export type EventSource = | "native" | `https://embed.example.com/${string}` diff --git a/__tests__/fixtures/test1/valibot.ts b/__tests__/fixtures/test1/valibot.ts index 4bc4ae8..abf3a8f 100644 --- a/__tests__/fixtures/test1/valibot.ts +++ b/__tests__/fixtures/test1/valibot.ts @@ -8,6 +8,7 @@ import type { JsonValue } from "type-fest"; import * as v from "valibot"; +import type { EmbedUrl } from "./types.js"; const jsonValueSchema: v.GenericSchema = v.lazy(() => v.union([ @@ -50,6 +51,32 @@ export const inputEmbedUrlSchema = v.pipe( v.custom<`https://embed.example.com/${string}`>(() => true), ); export const embedUrlSchema = inputEmbedUrlSchema; +export const inputEmbedLinkSchema = v.strictObject({ + url: v.pipe( + v.string(), + v.custom(() => true), + ), + label: v.optional( + v.pipe( + v.string(), + v.custom>(() => true), + ), + ), +}); +export const embedLinkSchema = v.strictObject({ + url: v.pipe( + v.string(), + v.trim(), + v.custom(() => true), + ), + label: v.exactOptional( + v.pipe( + v.string(), + v.trim(), + v.custom>(() => true), + ), + ), +}); export const inputEventSourceSchema = v.union([ v.picklist(["native"]), v.pipe( diff --git a/__tests__/typescript-hint.test.ts b/__tests__/typescript-hint.test.ts index d18be04..79fc471 100644 --- a/__tests__/typescript-hint.test.ts +++ b/__tests__/typescript-hint.test.ts @@ -75,3 +75,40 @@ test("x-typescript-hint honored inside anyOf branches", async () => { expect(result.typesFile.getText()).toMatchSnapshot("types.ts"); }); + +test("x-typescript-hint naming a generated type imports it into the validators", async () => { + const schema: oas32.OpenAPIObject = { + ...baseDoc, + components: { + schemas: { + EmbedUrl: { + type: "string", + "x-typescript-hint": "`https://embed.example.com/${string}`", + }, + EmbedLink: { + type: "object", + properties: { + url: { type: "string", "x-typescript-hint": "EmbedUrl" }, + label: { + type: "string", + "x-typescript-hint": "Capitalize", + }, + }, + }, + }, + }, + }; + + const result = await processOpenApiDocument( + "/tmp/like-you-know-whatever", + schema, + ); + + const imported = result.valibotFile + .getImportDeclarations() + .flatMap((declaration) => declaration.getNamedImports()) + .map((specifier) => specifier.getName()); + + expect(imported).toContain("EmbedUrl"); + expect(imported).not.toContain("Capitalize"); +}); diff --git a/lib/process-document.ts b/lib/process-document.ts index cf8a524..e39df73 100644 --- a/lib/process-document.ts +++ b/lib/process-document.ts @@ -47,7 +47,7 @@ import { createValibotFile, createValidatorForOperationInput, declareValidator, - importLazyTypes, + importNamedTypes, type ValidatorEntry, registerValidatorFromSchema, addJsonValueSchemaWhenUsed, @@ -2341,9 +2341,9 @@ export async function processOpenApiDocument( files.typesFile.fixUnusedIdentifiers(); files.commandsFile.fixUnusedIdentifiers(); files.commandsValidatedFile.fixUnusedIdentifiers(); - importLazyTypes( + importNamedTypes( files.valibotFile, - documentCtx.validators, + files.typesFile, buildTypesModuleSpecifier(files.typesFile), ); addJsonValueSchemaWhenUsed(files.valibotFile); diff --git a/lib/valibot.ts b/lib/valibot.ts index 9bcbeb5..4c97ba4 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { oas30, oas32 } from "openapi3-ts"; import { type CodeBlockWriter, + Node, type Project, type SourceFile, SyntaxKind, @@ -36,8 +37,6 @@ export type ValidatorEntry = { emitted: boolean; }; -const lazilyTyped = new WeakMap, Set>(); - // oxlint groups integer digits in threes once a literal reaches five digits function numericLiteral(value: number) { const text = String(value); @@ -273,10 +272,6 @@ function resolveRef( // A recursive schema refers to one that is not declared yet. The // annotation stops TypeScript inferring its type from itself - const types = lazilyTyped.get(validators) ?? new Set(); - types.add(entry.type); - lazilyTyped.set(validators, types); - const type = mode === "input" || entry.wireIsInput ? `UndefinedOnPartialDeep<${entry.type}>` @@ -287,27 +282,38 @@ function resolveRef( } /** - * Imports the types that annotate lazy refs. Call it once every validator - * is emitted + * Imports each type the types module exports and a validator names, whether a + * lazy ref annotates it or an `x-typescript-hint` names it. Call it once every + * validator is emitted */ -export function importLazyTypes( +export function importNamedTypes( file: SourceFile, - validators: Map, + typesFile: SourceFile, typesModuleSpecifier: string, ) { - const types = lazilyTyped.get(validators); + const exported = typesFile.getExportedDeclarations(); + + const named = new Set( + file + .getDescendantsOfKind(SyntaxKind.TypeReference) + .map((reference) => reference.getTypeName()) + .filter((name) => Node.isIdentifier(name)) + .map((name) => name.getText()), + ); - if (!types) { - return; - } + const fromTypes = [...named].filter((name) => exported.has(name)); - file.addImportDeclaration({ - moduleSpecifier: typesModuleSpecifier, - namedImports: [...types], - isTypeOnly: true, - }); + if (fromTypes.length > 0) { + file.addImportDeclaration({ + moduleSpecifier: typesModuleSpecifier, + namedImports: fromTypes.toSorted(), + isTypeOnly: true, + }); + } - importFromTypeFest(file, "UndefinedOnPartialDeep"); + if (named.has("UndefinedOnPartialDeep")) { + importFromTypeFest(file, "UndefinedOnPartialDeep"); + } } function importFromTypeFest(file: SourceFile, name: string) { From 3815d352d504ab9817d4039b3e1982e9337efb69 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 7 Nov 2026 19:33:35 +0800 Subject: [PATCH 2/4] refactor: keep each temporal format's regex and type in one table The regex that checks a format and the template-literal type it produces were written out in both generators, so a change to one could miss the other. Co-Authored-By: LLM --- .../docker/.openapi-codegen-manifest.json | 2 +- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 2 +- lib/process-schema.ts | 30 +--------- lib/temporal.ts | 34 +++++++++++ lib/valibot.ts | 59 ++----------------- 7 files changed, 47 insertions(+), 84 deletions(-) create mode 100644 lib/temporal.ts diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index 08d7ca4..bc3e154 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "5c0951f8416ec95d05f33ca493ed6922", + "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", "main.ts": "38065305823906f3aa1c7f968e278002", diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 06dfbaa..2df97e7 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "5c0951f8416ec95d05f33ca493ed6922", + "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index ef85a11..16f11c0 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "5c0951f8416ec95d05f33ca493ed6922", + "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index ca672b2..2b2d359 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "5c0951f8416ec95d05f33ca493ed6922", + "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "9e314cc81e615676b0b6e8a0af8d330f", "main.ts": "5c768be2e48d0b06d7ed8da665653404", diff --git a/lib/process-schema.ts b/lib/process-schema.ts index 63be389..0d8ef31 100644 --- a/lib/process-schema.ts +++ b/lib/process-schema.ts @@ -13,6 +13,7 @@ import { } from "ts-morph"; import { openChunk } from "./chunks.ts"; import { extractSchemaName, schemaRef } from "./refs.ts"; +import { temporalFormat } from "./temporal.ts"; import { type SchemaNode, type SchemaObject, @@ -27,26 +28,6 @@ function maybeWithNullUnion(type: string | WriterFunction, withNull = false) { return withNull && type !== "null" ? Writers.unionType(type, "null") : type; } -// Template-literal type per RFC 3339 temporal format, mirrored in valibot -function temporalStringType(format: string | undefined) { - switch (format) { - case "date": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}`"; - case "time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}:${number}:${number}${string}`"; - case "date-time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}T${number}:${number}:${number}${string}`"; - case "duration": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`P${string}`"; - default: - return; - } -} - // int64 maps to bigint, and every other integer or number maps to number function numericType(isInt64: boolean, stringish: boolean | undefined) { // int64 reaches past Number.MAX_SAFE_INTEGER, and query, header, path and @@ -527,9 +508,7 @@ function stringType(schemaObject: SchemaObject) { return schemaObject["x-typescript-hint"]; } - const temporal = temporalStringType(schemaObject.format); - - return temporal ?? "string"; + return temporalFormat(schemaObject.format)?.type ?? "string"; } function schemaObjectType( @@ -799,11 +778,8 @@ function stringAliasType(schemaObject: SchemaObject) { ); } - // RFC 3339 temporal formats (date, date-time, time, duration) - const temporal = temporalStringType(schemaObject.format); - return maybeWithNullUnion( - temporal || "string", + temporalFormat(schemaObject.format)?.type ?? "string", schemaTypeIsNull(schemaObject), ); } diff --git a/lib/temporal.ts b/lib/temporal.ts new file mode 100644 index 0000000..77b8a1e --- /dev/null +++ b/lib/temporal.ts @@ -0,0 +1,34 @@ +/** + * Maps an RFC 3339 temporal format to the regex literal that checks a value, + * and the template-literal type the generated code gives it + */ +export function temporalFormat(format: string | undefined) { + // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` + // and `time` require an offset. The seconds field admits a leap second of + // `60`. `T` and `Z` may be lower case, and a space may separate the date + // from the time. `duration` follows the ISO 8601 grammar in Appendix A + switch (format) { + case "date": + return { + regex: String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u`, + type: "`${number}-${number}-${number}`", + }; + case "time": + return { + regex: String.raw`/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, + type: "`${number}:${number}:${number}${string}`", + }; + case "date-time": + return { + regex: String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, + type: "`${number}-${number}-${number}T${number}:${number}:${number}${string}`", + }; + case "duration": + return { + regex: String.raw`/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u`, + type: "`P${string}`", + }; + default: + return; + } +} diff --git a/lib/valibot.ts b/lib/valibot.ts index 4c97ba4..48e100f 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -14,6 +14,7 @@ import type { Primitive } from "type-fest"; import type * as v from "valibot"; import { openChunk } from "./chunks.ts"; import { schemaRef } from "./refs.ts"; +import { temporalFormat } from "./temporal.ts"; import { type SchemaNode, type SchemaObject, @@ -145,63 +146,15 @@ const noTrimFormats = new Set(["uuid", "byte", "binary", "password"]); // RFC 3339 temporal formats, validated by regex at runtime function temporalRegexConstraint(format: string | undefined) { - // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` - // and `time` require an offset. The seconds field admits a leap second of - // `60`. `T` and `Z` may be lower case, and a space may separate the date - // from the time. `duration` follows the ISO 8601 grammar in Appendix A - switch (format) { - case "date": - return vcall( - "regex", - String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u`, - JSON.stringify(format), - ); - case "time": - return vcall( - "regex", - String.raw`/^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, - JSON.stringify(format), - ); - case "date-time": - return vcall( - "regex", - String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, - JSON.stringify(format), - ); - case "duration": - return vcall( - "regex", - String.raw`/^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u`, - JSON.stringify(format), - ); - default: - return; - } -} - -// Template-literal type per temporal format, mirrored in process-schema -function temporalTypeHint(format: string | undefined) { - switch (format) { - case "date": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}`"; - case "time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}:${number}:${number}${string}`"; - case "date-time": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`${number}-${number}-${number}T${number}:${number}:${number}${string}`"; - case "duration": - // biome-ignore lint/suspicious/noTemplateCurlyInString: template literal type - return "`P${string}`"; - default: - return; - } + const temporal = temporalFormat(format); + return temporal + ? vcall("regex", temporal.regex, JSON.stringify(format)) + : undefined; } // Narrows the inferred output to the template-literal type, past the regex function temporalHintSchema(format: string | undefined) { - const type = temporalTypeHint(format); + const type = temporalFormat(format)?.type; return type ? `v.custom<${type}>(() => true)` : undefined; } From 42672a74b5c5759aea35d5ed94c1e20fef1e230d Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 7 Nov 2026 19:38:53 +0800 Subject: [PATCH 3/4] fix: type a date-time separated by a lower-case t or a space The date-time regex accepts `T`, `t` or a space between the date and the time, as RFC 3339 allows, but the generated type admitted only `T`. A value such as `2024-01-01t00:00:00Z` passed validation and still did not assign to the type. Co-Authored-By: LLM --- .../__snapshots__/nullables.test.ts.snap | 6 +- .../docker/.openapi-codegen-manifest.json | 6 +- __tests__/fixtures/docker/types.ts | 8 +- __tests__/fixtures/docker/valibot.ts | 16 ++-- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- __tests__/fixtures/test1.json | 12 +++ .../test1/.openapi-codegen-manifest.json | 6 +- __tests__/fixtures/test1/types.ts | 5 +- __tests__/fixtures/test1/valibot.ts | 49 +++++++++++- __tests__/temporal-formats.test.ts | 76 +++++++++++++++++++ lib/temporal.ts | 4 +- 12 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 __tests__/temporal-formats.test.ts diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap index 36c411b..0b0f7b8 100644 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ b/__tests__/__snapshots__/nullables.test.ts.snap @@ -88,7 +88,7 @@ export const nullablesSchema = v.looseObject( exports[`RFC 3339 temporal formats > types.ts 1`] = ` "export type MyDate = \`\${number}-\${number}-\${number}\`; export type MyTime = \`\${number}:\${number}:\${number}\${string}\`; -export type MyDateTime = \`\${number}-\${number}-\${number}T\${number}:\${number}:\${number}\${string}\`; +export type MyDateTime = \`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`; export type MyDuration = \`P\${string}\`; " `; @@ -100,8 +100,8 @@ export const inputMyDateSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0 export const myDateSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u, "date"), v.custom<\`\${number}-\${number}-\${number}\`>(() => true)); export const inputMyTimeSchema = v.pipe(v.string(), v.regex(/^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "time"), v.custom<\`\${number}:\${number}:\${number}\${string}\`>(() => true)); export const myTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "time"), v.custom<\`\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const inputMyDateTimeSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}T\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}T\${number}:\${number}:\${number}\${string}\`>(() => true)); +export const inputMyDateTimeSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>(() => true)); +export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>(() => true)); export const inputMyDurationSchema = v.pipe(v.string(), v.regex(/^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u, "duration"), v.custom<\`P\${string}\`>(() => true)); export const myDurationSchema = v.pipe(v.string(), v.trim(), v.regex(/^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u, "duration"), v.custom<\`P\${string}\`>(() => true)); " diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index bc3e154..df52317 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", + "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", - "types.ts": "f1e7d6c61bb9e15c5034a3cc3cc729b5", + "types.ts": "b26ed33a9b2d2f36998ab4e6b81352a0", "main.ts": "38065305823906f3aa1c7f968e278002", - "valibot.ts": "25d47efaf15282d72c20aed794bd32da", + "valibot.ts": "677e0fb39ce94c79a495b2b6b86d6ef3", "hono.ts": "4dee43760e6f999bf0da89d4c063bcd4", "commands-validated.ts": "633c805329787be3de004224eeb86e5c", "enums.ts": "6d2420ab590db68349e2e8463a1b026d" diff --git a/__tests__/fixtures/docker/types.ts b/__tests__/fixtures/docker/types.ts index 93c4736..5932a05 100644 --- a/__tests__/fixtures/docker/types.ts +++ b/__tests__/fixtures/docker/types.ts @@ -308,7 +308,7 @@ export type Health = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. @@ -338,7 +338,7 @@ export type HealthcheckResult = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. @@ -5434,7 +5434,7 @@ export type ContainerState = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. @@ -7826,7 +7826,7 @@ export type ContainerInspectCommandOutput = { * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. * @example 2020-01-04T10:44:24.496525531Z */ - Start?: `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + Start?: `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; /** * Date and time at which this check ended in * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. diff --git a/__tests__/fixtures/docker/valibot.ts b/__tests__/fixtures/docker/valibot.ts index bb7fd22..4e47804 100644 --- a/__tests__/fixtures/docker/valibot.ts +++ b/__tests__/fixtures/docker/valibot.ts @@ -1538,7 +1538,7 @@ export const inputHealthSchema = v.nullable( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -1603,7 +1603,7 @@ export const healthSchema = v.nullable( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -1649,7 +1649,7 @@ export const inputHealthcheckResultSchema = v.nullable( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -1688,7 +1688,7 @@ export const healthcheckResultSchema = v.nullable( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -20827,7 +20827,7 @@ export const inputContainerStateSchema = v.nullable( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -20963,7 +20963,7 @@ export const containerStateSchema = v.nullable( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -28294,7 +28294,7 @@ export const inputContainerInspectCommandResponseSchema = v.looseObject({ /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), @@ -29719,7 +29719,7 @@ export const containerInspectCommandResponseSchema = v.looseObject({ /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ), diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index 2df97e7..c3051bd 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", + "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 16f11c0..0d5c0b0 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", + "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1.json b/__tests__/fixtures/test1.json index 600c3bd..03e1aa4 100644 --- a/__tests__/fixtures/test1.json +++ b/__tests__/fixtures/test1.json @@ -40,6 +40,18 @@ "type": "string", "format": "date-time" }, + "CalendarDate": { + "type": "string", + "format": "date" + }, + "WallTime": { + "type": "string", + "format": "time" + }, + "TrialLength": { + "type": "string", + "format": "duration" + }, "Email": { "description": "Valid email address with fully qualified public top-level domain", "type": "string", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 2b2d359..2309387 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "ecaf319cfd9d2ad65eb4b32865c61ded", + "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", - "types.ts": "9e314cc81e615676b0b6e8a0af8d330f", + "types.ts": "03487f5ea8bf5b977329f4e71a1bfb39", "main.ts": "5c768be2e48d0b06d7ed8da665653404", - "valibot.ts": "20a8711d7b15c3074d7d21d33cb4a0f7", + "valibot.ts": "0fc93871e5ec7f9c4d7e269d8c86ecb3", "hono.ts": "12cc7ef64bfc4acf632ba9d9011880d8", "commands-validated.ts": "afd10b6462e83dbe33e5d42dd227cd97", "enums.ts": "26ade5ccad3bf30118ce8691b99b7c91" diff --git a/__tests__/fixtures/test1/types.ts b/__tests__/fixtures/test1/types.ts index 84291c7..66358da 100644 --- a/__tests__/fixtures/test1/types.ts +++ b/__tests__/fixtures/test1/types.ts @@ -8,6 +8,9 @@ import type { JsonValue, UndefinedOnPartialDeep } from "type-fest"; +export type CalendarDate = `${number}-${number}-${number}`; +export type WallTime = `${number}:${number}:${number}${string}`; +export type TrialLength = `P${string}`; export type PromoCode = string; /** The API version */ export type ApiVersion = "2024-01-01"; @@ -29,7 +32,7 @@ export type EventSource = | `https://syndicated.example.com/${string}`; export type StripeId = string; export type DateTime = - `${number}-${number}-${number}T${number}:${number}:${number}${string}`; + `${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`; export type BillingSubscriptionStatus = "active" | "inactive"; export type BillingSubscriptionInterval = "monthly" | "yearly"; export type PlanSku = diff --git a/__tests__/fixtures/test1/valibot.ts b/__tests__/fixtures/test1/valibot.ts index abf3a8f..b894520 100644 --- a/__tests__/fixtures/test1/valibot.ts +++ b/__tests__/fixtures/test1/valibot.ts @@ -20,6 +20,51 @@ const jsonValueSchema: v.GenericSchema = v.lazy(() => v.array(jsonValueSchema), ]), ); +export const inputCalendarDateSchema = v.pipe( + v.string(), + v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), + v.custom<`${number}-${number}-${number}`>(() => true), +); +export const calendarDateSchema = v.pipe( + v.string(), + v.trim(), + v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), + v.custom<`${number}-${number}-${number}`>(() => true), +); +export const inputWallTimeSchema = v.pipe( + v.string(), + v.regex( + /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + "time", + ), + v.custom<`${number}:${number}:${number}${string}`>(() => true), +); +export const wallTimeSchema = v.pipe( + v.string(), + v.trim(), + v.regex( + /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + "time", + ), + v.custom<`${number}:${number}:${number}${string}`>(() => true), +); +export const inputTrialLengthSchema = v.pipe( + v.string(), + v.regex( + /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, + "duration", + ), + v.custom<`P${string}`>(() => true), +); +export const trialLengthSchema = v.pipe( + v.string(), + v.trim(), + v.regex( + /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, + "duration", + ), + v.custom<`P${string}`>(() => true), +); export const inputPromoCodeSchema = v.pipe( v.string(), v.minLength(1), @@ -104,7 +149,7 @@ export const inputDateTimeSchema = v.pipe( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ); @@ -115,7 +160,7 @@ export const dateTimeSchema = v.pipe( /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, "date-time", ), - v.custom<`${number}-${number}-${number}T${number}:${number}:${number}${string}`>( + v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( () => true, ), ); diff --git a/__tests__/temporal-formats.test.ts b/__tests__/temporal-formats.test.ts new file mode 100644 index 0000000..b1708e1 --- /dev/null +++ b/__tests__/temporal-formats.test.ts @@ -0,0 +1,76 @@ +import * as v from "valibot"; +import { expect, test } from "vitest"; +import type { + CalendarDate, + DateTime, + TrialLength, + WallTime, +} from "./fixtures/test1/types.ts"; +import { + calendarDateSchema, + dateTimeSchema, + trialLengthSchema, + wallTimeSchema, +} from "./fixtures/test1/valibot.ts"; + +// regex-accepted values, so `satisfies` fails where a format's type is narrower than its regex +const dates = [ + "2024-02-29", + "0000-01-01", + "9999-12-31", +] as const satisfies readonly CalendarDate[]; + +const times = [ + "00:00:00Z", + "12:30:45z", + "23:59:60.123456+05:30", + "08:15:00-08:00", +] as const satisfies readonly WallTime[]; + +const dateTimes = [ + "2024-01-01T00:00:00Z", + "2024-01-01t00:00:00z", + "2024-01-01 23:59:60.5+05:30", + "1999-12-31T23:59:59-00:00", +] as const satisfies readonly DateTime[]; + +const durations = [ + "P1Y2M3W4DT5H6M7.5S", + "PT0S", + "P1W", + "PT1.5S", +] as const satisfies readonly TrialLength[]; + +test.each(dates)("a date %j parses and has the date type", (value) => { + expect(v.safeParse(calendarDateSchema, value)).toMatchObject({ + success: true, + output: value, + }); +}); + +test.each(times)("a time %j parses and has the time type", (value) => { + expect(v.safeParse(wallTimeSchema, value)).toMatchObject({ + success: true, + output: value, + }); +}); + +test.each(dateTimes)( + "a date-time %j parses and has the date-time type", + (value) => { + expect(v.safeParse(dateTimeSchema, value)).toMatchObject({ + success: true, + output: value, + }); + }, +); + +test.each(durations)( + "a duration %j parses and has the duration type", + (value) => { + expect(v.safeParse(trialLengthSchema, value)).toMatchObject({ + success: true, + output: value, + }); + }, +); diff --git a/lib/temporal.ts b/lib/temporal.ts index 77b8a1e..a3b6f58 100644 --- a/lib/temporal.ts +++ b/lib/temporal.ts @@ -1,6 +1,6 @@ /** * Maps an RFC 3339 temporal format to the regex literal that checks a value, - * and the template-literal type the generated code gives it + * and the template-literal type every match satisfies */ export function temporalFormat(format: string | undefined) { // RFC 3339 departs from ISO 8601 in ways these patterns encode. `date-time` @@ -21,7 +21,7 @@ export function temporalFormat(format: string | undefined) { case "date-time": return { regex: String.raw`/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u`, - type: "`${number}-${number}-${number}T${number}:${number}:${number}${string}`", + type: '`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`', }; case "duration": return { From 3a24cf283742f336450bed049d6fe3a975dd9261 Mon Sep 17 00:00:00 2001 From: Max Holman Date: Sat, 7 Nov 2026 19:46:58 +0800 Subject: [PATCH 4/4] fix: check a temporal format in the schema that types it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated validator ran the format's regex in `v.regex` and then narrowed the output with `v.custom<…>(() => true)`, a step that accepts anything. One `v.custom` now runs the regex and types the output, and a component schema names its alias from the types module, so the template literal is written out once. A failed check reports a `custom` issue where it reported `regex`. Co-Authored-By: LLM --- .../__snapshots__/nullables.test.ts.snap | 19 ++-- .../temporal-formats.test.ts.snap | 24 +++++ .../docker/.openapi-codegen-manifest.json | 4 +- __tests__/fixtures/docker/valibot.ts | 88 ++++++++++--------- .../openai/.openapi-codegen-manifest.json | 2 +- .../petstore/.openapi-codegen-manifest.json | 2 +- .../test1/.openapi-codegen-manifest.json | 4 +- __tests__/fixtures/test1/valibot.ts | 82 +++++++++++------ __tests__/temporal-formats.test.ts | 81 ++++++++++++++++- lib/valibot.ts | 64 ++++++++++---- 10 files changed, 272 insertions(+), 98 deletions(-) create mode 100644 __tests__/__snapshots__/temporal-formats.test.ts.snap diff --git a/__tests__/__snapshots__/nullables.test.ts.snap b/__tests__/__snapshots__/nullables.test.ts.snap index 0b0f7b8..5bf8b2e 100644 --- a/__tests__/__snapshots__/nullables.test.ts.snap +++ b/__tests__/__snapshots__/nullables.test.ts.snap @@ -95,15 +95,16 @@ export type MyDuration = \`P\${string}\`; exports[`RFC 3339 temporal formats > valibot.ts 1`] = ` "import * as v from "valibot"; - -export const inputMyDateSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u, "date"), v.custom<\`\${number}-\${number}-\${number}\`>(() => true)); -export const myDateSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u, "date"), v.custom<\`\${number}-\${number}-\${number}\`>(() => true)); -export const inputMyTimeSchema = v.pipe(v.string(), v.regex(/^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "time"), v.custom<\`\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const myTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "time"), v.custom<\`\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const inputMyDateTimeSchema = v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>(() => true)); -export const inputMyDurationSchema = v.pipe(v.string(), v.regex(/^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u, "duration"), v.custom<\`P\${string}\`>(() => true)); -export const myDurationSchema = v.pipe(v.string(), v.trim(), v.regex(/^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u, "duration"), v.custom<\`P\${string}\`>(() => true)); +import type { MyDate, MyDateTime, MyDuration, MyTime } from "./types.js"; + +export const inputMyDateSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u.test(input), "date")); +export const myDateSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$/u.test(input), "date")); +export const inputMyTimeSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "time")); +export const myTimeSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "time")); +export const inputMyDateTimeSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const myDateTimeSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const inputMyDurationSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u.test(input), "duration")); +export const myDurationSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^P(?!$)((\\d+Y)?(\\d+M)?(\\d+W)?(\\d+D)?)(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$/u.test(input), "duration")); " `; diff --git a/__tests__/__snapshots__/temporal-formats.test.ts.snap b/__tests__/__snapshots__/temporal-formats.test.ts.snap new file mode 100644 index 0000000..ef57637 --- /dev/null +++ b/__tests__/__snapshots__/temporal-formats.test.ts.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`a temporal schema names its alias, inlines a property's literal, and defers to a type hint > valibot.ts 1`] = ` +"import * as v from "valibot"; +import type { Timestamp } from "./types.js"; + +export const inputTimestampSchema = v.pipe(v.string(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const timestampSchema = v.pipe(v.string(), v.trim(), v.custom((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")); +export const inputWindowSchema = v.strictObject( + { + "opensAt": v.pipe(v.string(), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")) + , + "closesAt": v.optional(v.pipe(v.string(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom(() => true))) + , + }); +export const windowSchema = v.strictObject( + { + "opensAt": v.pipe(v.string(), v.trim(), v.custom<\`\${number}-\${number}-\${number}\${"T" | "t" | " "}\${number}:\${number}:\${number}\${string}\`>((input) => typeof input === "string" && /^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u.test(input), "date-time")) + , + "closesAt": v.exactOptional(v.pipe(v.string(), v.trim(), v.regex(/^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])[Tt ]([01]\\d|2[0-3]):[0-5]\\d:([0-5]\\d|60)(\\.\\d+)?([Zz]|[+-]([01]\\d|2[0-3]):[0-5]\\d)$/u, "date-time"), v.custom(() => true))) + , + }); +" +`; diff --git a/__tests__/fixtures/docker/.openapi-codegen-manifest.json b/__tests__/fixtures/docker/.openapi-codegen-manifest.json index df52317..e06450d 100644 --- a/__tests__/fixtures/docker/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/docker/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", + "#generator": "11919a7d1783b79b2423f29e557cf1ae", "commands.ts": "593afb99bb4daed5e9491b2a55d75d75", "types.ts": "b26ed33a9b2d2f36998ab4e6b81352a0", "main.ts": "38065305823906f3aa1c7f968e278002", - "valibot.ts": "677e0fb39ce94c79a495b2b6b86d6ef3", + "valibot.ts": "0715641305f23b10523ca4f6ad3e5e89", "hono.ts": "4dee43760e6f999bf0da89d4c063bcd4", "commands-validated.ts": "633c805329787be3de004224eeb86e5c", "enums.ts": "6d2420ab590db68349e2e8463a1b026d" diff --git a/__tests__/fixtures/docker/valibot.ts b/__tests__/fixtures/docker/valibot.ts index 4e47804..d9a541f 100644 --- a/__tests__/fixtures/docker/valibot.ts +++ b/__tests__/fixtures/docker/valibot.ts @@ -1534,12 +1534,13 @@ export const inputHealthSchema = v.nullable( Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -1599,12 +1600,13 @@ export const healthSchema = v.nullable( v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -1645,12 +1647,13 @@ export const inputHealthcheckResultSchema = v.nullable( Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -1684,12 +1687,13 @@ export const healthcheckResultSchema = v.nullable( v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -20823,12 +20827,13 @@ export const inputContainerStateSchema = v.nullable( Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -20959,12 +20964,13 @@ export const containerStateSchema = v.nullable( v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -28290,12 +28296,13 @@ export const inputContainerInspectCommandResponseSchema = v.looseObject({ Start: v.optional( v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), @@ -29715,12 +29722,13 @@ export const containerInspectCommandResponseSchema = v.looseObject({ v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, - "date-time", - ), v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), + "date-time", ), ), ), diff --git a/__tests__/fixtures/openai/.openapi-codegen-manifest.json b/__tests__/fixtures/openai/.openapi-codegen-manifest.json index c3051bd..347ad8a 100644 --- a/__tests__/fixtures/openai/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/openai/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", + "#generator": "a1d1be2090fb6bef18a3285d1780a1bc", "commands.ts": "99ac148005b23e09a53adb6b62620dc0", "types.ts": "bc453b1a7fcd330909a23ae01be97f52", "main.ts": "8147604a37254400a015b78650466094", diff --git a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json index 0d5c0b0..9e9cec5 100644 --- a/__tests__/fixtures/petstore/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/petstore/.openapi-codegen-manifest.json @@ -1,5 +1,5 @@ { - "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", + "#generator": "a1d1be2090fb6bef18a3285d1780a1bc", "commands.ts": "69c6a9f2924568fc08fd489508c9df34", "types.ts": "ea65c3e67352d4e22b97af80085727b4", "main.ts": "64edb526dcbcbd345e631ccff959f11d", diff --git a/__tests__/fixtures/test1/.openapi-codegen-manifest.json b/__tests__/fixtures/test1/.openapi-codegen-manifest.json index 2309387..879fa69 100644 --- a/__tests__/fixtures/test1/.openapi-codegen-manifest.json +++ b/__tests__/fixtures/test1/.openapi-codegen-manifest.json @@ -1,9 +1,9 @@ { - "#generator": "59a04039491ccc38b5e5cd0a0c2a262f", + "#generator": "a1d1be2090fb6bef18a3285d1780a1bc", "commands.ts": "eab0f690af8f1e2d973f6504de470ba9", "types.ts": "03487f5ea8bf5b977329f4e71a1bfb39", "main.ts": "5c768be2e48d0b06d7ed8da665653404", - "valibot.ts": "0fc93871e5ec7f9c4d7e269d8c86ecb3", + "valibot.ts": "edf27c46cd7807a4419b9693b128c448", "hono.ts": "12cc7ef64bfc4acf632ba9d9011880d8", "commands-validated.ts": "afd10b6462e83dbe33e5d42dd227cd97", "enums.ts": "26ade5ccad3bf30118ce8691b99b7c91" diff --git a/__tests__/fixtures/test1/valibot.ts b/__tests__/fixtures/test1/valibot.ts index b894520..83e9e92 100644 --- a/__tests__/fixtures/test1/valibot.ts +++ b/__tests__/fixtures/test1/valibot.ts @@ -8,7 +8,13 @@ import type { JsonValue } from "type-fest"; import * as v from "valibot"; -import type { EmbedUrl } from "./types.js"; +import type { + CalendarDate, + DateTime, + EmbedUrl, + TrialLength, + WallTime, +} from "./types.js"; const jsonValueSchema: v.GenericSchema = v.lazy(() => v.union([ @@ -22,48 +28,68 @@ const jsonValueSchema: v.GenericSchema = v.lazy(() => ); export const inputCalendarDateSchema = v.pipe( v.string(), - v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), - v.custom<`${number}-${number}-${number}`>(() => true), + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u.test(input), + "date", + ), ); export const calendarDateSchema = v.pipe( v.string(), v.trim(), - v.regex(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u, "date"), - v.custom<`${number}-${number}-${number}`>(() => true), + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/u.test(input), + "date", + ), ); export const inputWallTimeSchema = v.pipe( v.string(), - v.regex( - /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom( + (input) => + typeof input === "string" && + /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "time", ), - v.custom<`${number}:${number}:${number}${string}`>(() => true), ); export const wallTimeSchema = v.pipe( v.string(), v.trim(), - v.regex( - /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom( + (input) => + typeof input === "string" && + /^([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "time", ), - v.custom<`${number}:${number}:${number}${string}`>(() => true), ); export const inputTrialLengthSchema = v.pipe( v.string(), - v.regex( - /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, + v.custom( + (input) => + typeof input === "string" && + /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u.test( + input, + ), "duration", ), - v.custom<`P${string}`>(() => true), ); export const trialLengthSchema = v.pipe( v.string(), v.trim(), - v.regex( - /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u, + v.custom( + (input) => + typeof input === "string" && + /^P(?!$)((\d+Y)?(\d+M)?(\d+W)?(\d+D)?)(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/u.test( + input, + ), "duration", ), - v.custom<`P${string}`>(() => true), ); export const inputPromoCodeSchema = v.pipe( v.string(), @@ -145,24 +171,26 @@ export const inputStripeIdSchema = v.pipe( export const stripeIdSchema = inputStripeIdSchema; export const inputDateTimeSchema = v.pipe( v.string(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, - ), ); export const dateTimeSchema = v.pipe( v.string(), v.trim(), - v.regex( - /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u, + v.custom( + (input) => + typeof input === "string" && + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])[Tt ]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)$/u.test( + input, + ), "date-time", ), - v.custom<`${number}-${number}-${number}${"T" | "t" | " "}${number}:${number}:${number}${string}`>( - () => true, - ), ); export const inputBillingSubscriptionStatusSchema = v.picklist([ "active", diff --git a/__tests__/temporal-formats.test.ts b/__tests__/temporal-formats.test.ts index b1708e1..c9782dc 100644 --- a/__tests__/temporal-formats.test.ts +++ b/__tests__/temporal-formats.test.ts @@ -1,5 +1,7 @@ +import type { oas32 } from "openapi3-ts"; import * as v from "valibot"; -import { expect, test } from "vitest"; +import { expect, expectTypeOf, test } from "vitest"; +import { processOpenApiDocument } from "../lib/process-document.ts"; import type { CalendarDate, DateTime, @@ -9,6 +11,10 @@ import type { import { calendarDateSchema, dateTimeSchema, + inputCalendarDateSchema, + inputDateTimeSchema, + inputTrialLengthSchema, + inputWallTimeSchema, trialLengthSchema, wallTimeSchema, } from "./fixtures/test1/valibot.ts"; @@ -74,3 +80,76 @@ test.each(durations)( }); }, ); + +test("a temporal format's schemas output its generated type", () => { + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); + expectTypeOf< + v.InferOutput + >().toEqualTypeOf(); +}); + +test.each([ + ["date-time", dateTimeSchema, "2024-13-01T00:00:00Z"], + ["date-time", dateTimeSchema, "2024-01-01T00:00:00"], + ["date-time", dateTimeSchema, "2024-01-01"], + ["date-time", inputDateTimeSchema, "2024-01-01T24:00:00Z"], + ["date", calendarDateSchema, "2024-1-01"], + ["date", inputCalendarDateSchema, "2024-01-32"], + ["time", wallTimeSchema, "12:00:00"], + ["time", inputWallTimeSchema, "12:60:00Z"], + ["duration", trialLengthSchema, "P"], + ["duration", inputTrialLengthSchema, "PT"], + ["duration", trialLengthSchema, "1D"], +])("a malformed %s %j is rejected", (_format, schema, value) => { + expect(v.safeParse(schema, value).success).toBe(false); +}); + +test("a temporal schema names its alias, inlines a property's literal, and defers to a type hint", async () => { + const schema: oas32.OpenAPIObject = { + openapi: "3.1.0", + info: { title: "Test", version: "1.0.0" }, + paths: {}, + components: { + schemas: { + Timestamp: { type: "string", format: "date-time" }, + Window: { + type: "object", + additionalProperties: false, + required: ["opensAt"], + properties: { + opensAt: { type: "string", format: "date-time" }, + closesAt: { + type: "string", + format: "date-time", + "x-typescript-hint": "Timestamp", + }, + }, + }, + }, + }, + }; + + const result = await processOpenApiDocument("/tmp/whatever", schema); + + expect(result.valibotFile.getText()).toMatchSnapshot("valibot.ts"); +}); diff --git a/lib/valibot.ts b/lib/valibot.ts index 48e100f..d13f4aa 100644 --- a/lib/valibot.ts +++ b/lib/valibot.ts @@ -144,18 +144,25 @@ function minMaxProperties(schema: SchemaObject) { const noTrimFormats = new Set(["uuid", "byte", "binary", "password"]); -// RFC 3339 temporal formats, validated by regex at runtime -function temporalRegexConstraint(format: string | undefined) { +// Runs the format's regex in `v.custom` to type the output, or alone when a type hint names it +function temporalConstraint( + format: string | undefined, + typeName: string | undefined, + hinted: boolean, +) { const temporal = temporalFormat(format); - return temporal - ? vcall("regex", temporal.regex, JSON.stringify(format)) - : undefined; -} -// Narrows the inferred output to the template-literal type, past the regex -function temporalHintSchema(format: string | undefined) { - const type = temporalFormat(format)?.type; - return type ? `v.custom<${type}>(() => true)` : undefined; + if (!temporal) { + return; + } + + const message = JSON.stringify(format); + + if (hinted) { + return vcall("regex", temporal.regex, message); + } + + return `v.custom<${typeName ?? temporal.type}>((input) => typeof input === "string" && ${temporal.regex}.test(input), ${message})`; } function shouldCoerceString(schema: SchemaObject) { @@ -443,6 +450,7 @@ function stringValidator( mode: SchemaMode, isNullable: boolean, typescriptHintSchema: string | undefined, + temporalTypeName?: string, ) { const shouldTrim = mode === "wire" && @@ -455,7 +463,11 @@ function stringValidator( shouldTrim ? vcall("trim") : undefined, schema.format === "email" ? vcall("email") : undefined, schema.format === "uuid" ? vcall("uuid") : undefined, - temporalRegexConstraint(schema.format), + temporalConstraint( + schema.format, + temporalTypeName, + typescriptHintSchema !== undefined, + ), schema.minLength === undefined ? undefined @@ -466,8 +478,6 @@ function stringValidator( schema.pattern ? vcall("regex", `new RegExp(${regexSource(schema.pattern)})`) : undefined, - // A hint set by the `x-typescript-hint` extension wins over the format - typescriptHintSchema ? undefined : temporalHintSchema(schema.format), typescriptHintSchema, ), isNullable, @@ -892,6 +902,23 @@ export function createValibotFile(project: Project, outputDir: string) { return file; } +// Whether the schema's generated type alias is exactly its format's template literal +function isTemporalAlias(schema: SchemaNode): schema is SchemaObject { + return ( + isSchemaObject(schema) && + schema.type === "string" && + !("$ref" in schema) && + !("const" in schema) && + !schema.enum && + !schema.oneOf && + !schema.anyOf && + !schema.allOf && + !schemaIsNullable(schema) && + !extensionHintSchema(schema) && + temporalFormat(schema.format) !== undefined + ); +} + export function registerValidatorFromSchema( validators: Map, valibotFile: SourceFile, @@ -941,6 +968,13 @@ export function registerValidatorFromSchema( ] : []; + // A temporal alias's schema outputs the alias by name, so the template + // literal is written out once, in the types module + const initializer = (mode: SchemaMode) => + isTemporalAlias(schemaObject) + ? stringValidator(schemaObject, mode, false, undefined, entry.type) + : schemaToValidator(validators, schemaObject, mode); + // Input schema — always emitted (TS-side, allows undefined, no wire coercion) openChunk(valibotFile).addVariableStatement({ isExported: true, @@ -949,7 +983,7 @@ export function registerValidatorFromSchema( declarations: [ { name: inputName, - initializer: schemaToValidator(validators, schemaObject, "input"), + initializer: initializer("input"), }, ], }); @@ -966,7 +1000,7 @@ export function registerValidatorFromSchema( declarations: [ { name: wireName, - initializer: schemaToValidator(validators, schemaObject, "wire"), + initializer: initializer("wire"), }, ], });