Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions src/core/generateZodSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});"
`);
});
});

/**
Expand Down
134 changes: 112 additions & 22 deletions src/core/generateZodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
);
Expand All @@ -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);
Expand Down