diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts index 6e557d7..c9aefd4 100644 --- a/src/core/generateZodSchema.test.ts +++ b/src/core/generateZodSchema.test.ts @@ -1955,6 +1955,204 @@ describe("generateZodSchema", () => { ` ); }); + + it("should generate string refine validation for numeric formats on string types", () => { + const source = `export interface NumericStringFormats { + /** + * @format int64 + */ + int64Value: string; + + /** + * @format uint64 + */ + uint64Value: string; + + /** + * @format int32 + */ + int32Value: string; + + /** + * @format float32 + */ + float32Value: string; + + /** + * @format int64 Custom error message + */ + customErrorValue: string; + + /** + * @format int32 + */ + optionalInt32?: string; + }`; + + expect(generate(source)).toMatchInlineSnapshot(` + "export const numericStringFormatsSchema = z.object({ + /** + * @format int64 + */ + int64Value: z.string().refine(val => { try { + z.int64().parse(BigInt(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid int64 string" }), + /** + * @format uint64 + */ + uint64Value: z.string().refine(val => { try { + z.uint64().parse(BigInt(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid uint64 string" }), + /** + * @format int32 + */ + int32Value: z.string().refine(val => { try { + z.int32().parse(Number(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid int32 string" }), + /** + * @format float32 + */ + float32Value: z.string().refine(val => { try { + z.float32().parse(Number(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid float32 string" }), + /** + * @format int64 Custom error message + */ + customErrorValue: z.string().refine(val => { try { + z.int64().parse(BigInt(val)); + return true; + } + catch { + return false; + } }, { message: "Custom error message" }), + /** + * @format int32 + */ + optionalInt32: z.string().refine(val => { try { + z.int32().parse(Number(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid int32 string" }).optional() + });" + `); + }); + + it("should generate direct numeric schemas for numeric formats on number types", () => { + const source = `export interface NumericFormats { + /** + * @format int64 + */ + int64Value: number; + + /** + * @format uint64 + */ + uint64Value: number; + + /** + * @format int32 + */ + int32Value: number; + + /** + * @format float32 + */ + float32Value: number; + }`; + + expect(generate(source)).toMatchInlineSnapshot(` + "export const numericFormatsSchema = z.object({ + /** + * @format int64 + */ + int64Value: z.int64(), + /** + * @format uint64 + */ + uint64Value: z.uint64(), + /** + * @format int32 + */ + int32Value: z.int32(), + /** + * @format float32 + */ + float32Value: z.float32() + });" + `); + }); + + it("should handle optional and nullable properties with numeric formats on strings", () => { + const source = `export interface OptionalNumericStringFormats { + /** + * @format int64 + */ + optionalInt64?: string; + + /** + * @format uint32 + */ + nullableUint32: string | null; + + /** + * @format float32 + */ + optionalNullableFloat32?: string | null; + }`; + + expect(generate(source)).toMatchInlineSnapshot(` + "export const optionalNumericStringFormatsSchema = z.object({ + /** + * @format int64 + */ + optionalInt64: z.string().refine(val => { try { + z.int64().parse(BigInt(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid int64 string" }).optional(), + /** + * @format uint32 + */ + nullableUint32: z.string().refine(val => { try { + z.uint32().parse(Number(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid uint32 string" }).nullable(), + /** + * @format float32 + */ + optionalNullableFloat32: z.string().refine(val => { try { + z.float32().parse(Number(val)); + return true; + } + catch { + return false; + } }, { message: "Must be a valid float32 string" }).optional().nullable() + });" + `); + }); }); /** diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts index 2f3c993..e25e8d5 100644 --- a/src/core/generateZodSchema.ts +++ b/src/core/generateZodSchema.ts @@ -13,6 +13,28 @@ import { import { uniq } from "../utils/uniq.js"; import { camelCase, lowerCase } from "text-case"; +/** + * Numeric format types that require special handling for string types + */ +const NUMERIC_FORMATS = [ + "int", + "int32", + "uint32", + "int64", + "uint64", + "float32", + "float64", +] as const; + +/** + * Type guard to check if a format value is a numeric format + */ +function isNumericFormat( + formatValue: string +): formatValue is (typeof NUMERIC_FORMATS)[number] { + return (NUMERIC_FORMATS as readonly string[]).includes(formatValue); +} + export interface GenerateZodSchemaProps { /** * Name of the exported variable @@ -1058,6 +1080,95 @@ function buildZodPrimitiveInternal({ if (jsDocTags.format) { const formatValue = jsDocTags.format.value; + // Handle numeric format types on strings - these need custom validation + if (isNumericFormat(formatValue)) { + // Generate z.string().refine() for numeric formats on string types + const refineFunction = f.createArrowFunction( + undefined, + undefined, + [ + f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("val") + ), + ], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + f.createBlock([ + f.createTryStatement( + f.createBlock([ + f.createExpressionStatement( + f.createCallExpression( + f.createPropertyAccessExpression( + f.createCallExpression( + f.createPropertyAccessExpression( + f.createIdentifier(z), + f.createIdentifier(formatValue) + ), + undefined, + [] + ), + f.createIdentifier("parse") + ), + undefined, + formatValue === "int64" || formatValue === "uint64" + ? [ + f.createCallExpression( + f.createIdentifier("BigInt"), + undefined, + [f.createIdentifier("val")] + ), + ] + : [ + f.createCallExpression( + f.createIdentifier("Number"), + undefined, + [f.createIdentifier("val")] + ), + ] + ) + ), + f.createReturnStatement(f.createTrue()), + ]), + f.createCatchClause( + undefined, + f.createBlock([f.createReturnStatement(f.createFalse())]) + ), + undefined + ), + ]) + ); + + const errorMessage = + jsDocTags.format.errorMessage || + `Must be a valid ${formatValue} string`; + const refineOptions = f.createObjectLiteralExpression([ + f.createPropertyAssignment( + f.createIdentifier("message"), + f.createStringLiteral(errorMessage) + ), + ]); + + const stringSchema = buildZodSchema(z, "string", [], []); + const refineCall = f.createCallExpression( + f.createPropertyAccessExpression( + stringSchema, + f.createIdentifier("refine") + ), + undefined, + [refineFunction, refineOptions] + ); + + // Filter out format-related properties since we're handling the format validation with refine + const nonFormatProperties = zodProperties.filter( + (prop) => !isNumericFormat(prop.identifier) + ); + + // Apply zodProperties (like .optional(), .nullable(), etc.) to the refine call + return withZodProperties(refineCall, nonFormatProperties); + } + // Handle direct format validators (Zod v4 standalone methods) const directFormatMethods = [ "email", @@ -1105,9 +1216,8 @@ function buildZodPrimitiveInternal({ // Check for numeric format in JSDoc tags and generate appropriate Zod v4 schema if (jsDocTags.format) { const formatValue = jsDocTags.format.value; - const numericFormats = ["int", "float32", "float64", "int32", "uint32"]; - if (numericFormats.includes(formatValue)) { + if (isNumericFormat(formatValue)) { const nonFormatProperties = zodProperties.filter( (prop) => prop.identifier !== formatValue ); @@ -1126,26 +1236,6 @@ function buildZodPrimitiveInternal({ case ts.SyntaxKind.AnyKeyword: return buildZodSchema(z, "any", [], zodProperties); case ts.SyntaxKind.BigIntKeyword: - // Check for bigint format in JSDoc tags and generate appropriate Zod v4 schema - if (jsDocTags.format) { - const formatValue = jsDocTags.format.value; - const bigintFormats = ["int64", "uint64"]; - - if (bigintFormats.includes(formatValue)) { - const nonFormatProperties = zodProperties.filter( - (prop) => prop.identifier !== formatValue - ); - const formatArgs = jsDocTags.format.errorMessage - ? [f.createStringLiteral(jsDocTags.format.errorMessage)] - : []; - return buildZodSchema( - z, - formatValue, - formatArgs, - nonFormatProperties - ); - } - } return buildZodSchema(z, "bigint", [], zodProperties); case ts.SyntaxKind.VoidKeyword: return buildZodSchema(z, "void", [], zodProperties);