From e27d21fe4daeb8e51504ab8361eb06315be9771c Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 29 Jun 2026 19:52:02 -0300 Subject: [PATCH 1/8] yay it works --- packages/core/src/types.ts | 9 + packages/hono/src/index.ts | 13 +- packages/mcp/src/index.ts | 11 +- packages/orval/src/reusable-schemas.ts | 3 + packages/orval/src/utils/options.test.ts | 54 +++ packages/orval/src/utils/options.ts | 2 + packages/orval/src/write-zod-specs.test.ts | 38 +++ packages/orval/src/write-zod-specs.ts | 70 +++- packages/zod/src/compatible-v4.test.ts | 21 ++ packages/zod/src/compatible-v4.ts | 21 ++ packages/zod/src/index.ts | 378 +++++++++++++++++++-- packages/zod/src/zod.test.ts | 143 ++++++++ 12 files changed, 729 insertions(+), 34 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c438629ce6..08264d1f3c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -807,6 +807,8 @@ export interface ZodTimeOptions { */ export type ZodVersionOption = 3 | 4 | 'auto'; +export type ZodVariantOption = 'classic' | 'mini'; + interface BaseZodOptions { strict?: { param?: boolean; @@ -852,6 +854,12 @@ interface BaseZodOptions { } export interface ZodOptions extends BaseZodOptions { + /** + * Select the generated Zod API style. `classic` imports from `zod`; `mini` + * imports from `zod/mini` and emits the functional/check-based Zod Mini API. + * Zod Mini requires a Zod 4 target. + */ + variant?: ZodVariantOption; /** * Pin the Zod output target so generation is deterministic instead of * inferred from the installed `zod` version. Defaults to `'auto'`, which @@ -905,6 +913,7 @@ export type ZodCoerceType = | 'array'; export interface NormalizedZodOptions { + variant: ZodVariantOption; version: ZodVersionOption; strict: { param: boolean; diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 3eb43b18d3..a4d63269fe 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -30,7 +30,7 @@ import { type Tsconfig, upath, } from '@orval/core'; -import { generateZod } from '@orval/zod'; +import { generateZod, getZodImportSource } from '@orval/zod'; import fs from 'fs-extra'; import { @@ -42,6 +42,13 @@ import { } from './handler-merge'; import { getRoute } from './route'; +const getZodSchemaImportStatement = ( + variant: NormalizedOutputOptions['override']['zod']['variant'], +) => + variant === 'mini' + ? `import * as zod from '${getZodImportSource(variant)}';` + : `import { z as zod } from '${getZodImportSource(variant)}';`; + // Warn at most once per run when the optional `typescript` peer is missing and a // non-`skip` strategy was requested, so the degraded behavior is never silent. let warnedMissingTypeScript = false; @@ -926,7 +933,7 @@ const generateZodFiles = async ( oneMore: output.mode === 'tags-split', }); - let content = `${header}import { z as zod } from 'zod';\n${mutatorsImports}\n`; + let content = `${header}${getZodSchemaImportStatement(output.override.zod.variant)}\n${mutatorsImports}\n`; const zodPath = output.mode === 'tags' @@ -971,7 +978,7 @@ const generateZodFiles = async ( mutators: allMutators, }); - let content = `${header}import { z as zod } from 'zod';\n${mutatorsImports}\n`; + let content = `${header}${getZodSchemaImportStatement(output.override.zod.variant)}\n${mutatorsImports}\n`; const zodPath = nodePath.join(dirname, `${filename}.zod${extension}`); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index e427608a6e..6ecc91ad55 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -24,7 +24,14 @@ import { type Verbs, } from '@orval/core'; import { generateClient, generateFetchHeader } from '@orval/fetch'; -import { generateZod } from '@orval/zod'; +import { generateZod, getZodImportSource } from '@orval/zod'; + +const getZodSchemaImportStatement = ( + variant: NormalizedOutputOptions['override']['zod']['variant'], +) => + variant === 'mini' + ? `import * as zod from '${getZodImportSource(variant)}';` + : `import { z as zod } from '${getZodImportSource(variant)}';`; const getHeader = ( option: false | ((info: OpenApiInfoObject) => string | string[]), @@ -433,7 +440,7 @@ const generateZodFiles = async ( mutators: allMutators, }); - let content = `${header}import { z as zod } from 'zod';\n${mutatorsImports}\n`; + let content = `${header}${getZodSchemaImportStatement(output.override.zod.variant)}\n${mutatorsImports}\n`; const zodPath = path.join(dirname, `tool-schemas.zod${extension}`); diff --git a/packages/orval/src/reusable-schemas.ts b/packages/orval/src/reusable-schemas.ts index 6f2a1b20d3..ad4c9f42f2 100644 --- a/packages/orval/src/reusable-schemas.ts +++ b/packages/orval/src/reusable-schemas.ts @@ -3,6 +3,7 @@ import type { GeneratorMutator, OpenApiSchemaObject, ZodCoerceType, + ZodVariantOption, } from '@orval/core'; import { buildDynamicScope, getRefInfo } from '@orval/core'; import { @@ -141,6 +142,7 @@ export interface ReusableSchemaEntry { export interface GenerateReusableSchemaSetOptions { strict: boolean; isZodV4: boolean; + variant?: ZodVariantOption; coerce?: boolean | ZodCoerceType[]; /** Emit `.meta({ id, ... })` on each schema (zod v4). See `ZodOptions.generateMeta`. */ generateMeta?: boolean; @@ -228,6 +230,7 @@ export const generateReusableSchemaSet = ( schemaName: name, } : undefined, + options.variant, ); entries.push({ diff --git a/packages/orval/src/utils/options.test.ts b/packages/orval/src/utils/options.test.ts index 1706b45166..36d532b4be 100644 --- a/packages/orval/src/utils/options.test.ts +++ b/packages/orval/src/utils/options.test.ts @@ -1287,6 +1287,55 @@ describe('normalizeOptions', () => { } }); + it('defaults zod variant to classic and preserves mini when configured output-wide', async () => { + const workspace = await createTempWorkspace(); + + try { + const classic = await normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './classic.ts', + client: 'zod', + }, + }, + workspace, + ); + const mini = await normalizeOptions( + { + input: { + target: { + openapi: '3.1.0', + info: { title: 'Test', version: '1.0.0' }, + paths: {}, + }, + }, + output: { + target: './mini.ts', + client: 'zod', + override: { + zod: { + variant: 'mini', + }, + }, + }, + }, + workspace, + ); + + expect(classic.output.override.zod.variant).toBe('classic'); + expect(mini.output.override.zod.variant).toBe('mini'); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }); + it('resolves global zod mutators relative to the output workspace', async () => { const workspace = await createTempWorkspace(); @@ -1360,6 +1409,7 @@ describe('normalizeOptions', () => { zod: { strict: { body: true }, version: 3, + variant: 'mini', } as never, }, }, @@ -1387,6 +1437,10 @@ describe('normalizeOptions', () => { 'version' in (normalized.output.override.operations.listPets?.zod ?? {}), ).toBe(false); + expect( + 'variant' in + (normalized.output.override.operations.listPets?.zod ?? {}), + ).toBe(false); expect( 'generateMeta' in (normalized.output.override.tags.Pets?.zod ?? {}), ).toBe(false); diff --git a/packages/orval/src/utils/options.ts b/packages/orval/src/utils/options.ts index 1f61e0a919..aa2eafc9b8 100644 --- a/packages/orval/src/utils/options.ts +++ b/packages/orval/src/utils/options.ts @@ -648,6 +648,7 @@ export async function normalizeOptions( ), } : {}), + variant: outputOptions.override?.zod?.variant ?? 'classic', version: outputOptions.override?.zod?.version ?? 'auto', generateEachHttpStatus: outputOptions.override?.zod?.generateEachHttpStatus ?? false, @@ -986,6 +987,7 @@ function normalizeOperationsAndTags( ): Record { const unsupportedZodKeys = [ 'version', + 'variant', 'dateTimeOptions', 'timeOptions', 'generateEachHttpStatus', diff --git a/packages/orval/src/write-zod-specs.test.ts b/packages/orval/src/write-zod-specs.test.ts index 2af23cd4ee..80243f9702 100644 --- a/packages/orval/src/write-zod-specs.test.ts +++ b/packages/orval/src/write-zod-specs.test.ts @@ -36,6 +36,7 @@ const createOutputOptions = (): Parameters[4] => schemas: { suffix: '', itemSuffix: 'Item' }, }, zod: { + variant: 'classic', version: 'auto', strict: { body: true, @@ -107,6 +108,43 @@ describe('write-zod-specs regressions', () => { await fs.remove(root); }); + it('writes zod mini schema files with zod/mini imports', async () => { + const root = await fs.mkdtemp(path.join(tmpdir(), 'orval-zod-mini-')); + const schemasPath = path.join(root, 'schemas'); + const output = createOutputOptions(); + output.override.zod.variant = 'mini'; + output.override.zod.version = 4; + + const builder = { + spec: {}, + target: '', + schemas: [ + { + name: 'RangeSchema', + schema: { + type: 'number', + minimum: 2, + maximum: 10, + }, + }, + ], + } satisfies Parameters[0]; + + await writeZodSchemas(builder, schemasPath, '.ts', '', output); + + const fileContent = await fs.readFile( + path.join(schemasPath, 'RangeSchema.ts'), + 'utf8', + ); + + expect(fileContent).toContain("import * as zod from 'zod/mini';"); + expect(fileContent).toContain( + 'export const RangeSchema = zod.number().check(zod.gte(RangeSchemaMin)).check(zod.lte(RangeSchemaMax))', + ); + + await fs.remove(root); + }); + it("defaults 'auto' to zod v4 syntax when no packageJson is available", async () => { const root = await fs.mkdtemp(path.join(tmpdir(), 'orval-zod-')); const schemasPath = path.join(root, 'schemas'); diff --git a/packages/orval/src/write-zod-specs.ts b/packages/orval/src/write-zod-specs.ts index 7a053850a4..d8fd3b4e3c 100644 --- a/packages/orval/src/write-zod-specs.ts +++ b/packages/orval/src/write-zod-specs.ts @@ -20,12 +20,16 @@ import { type Tsconfig, upath, type ZodCoerceType, + type ZodVariantOption, type ZodVersionOption, } from '@orval/core'; import { + assertZodTarget, dereference, generateFormDataZodSchema, generateZodValidationSchemaDefinition, + getZodImportSource, + getZodTypeName, parseZodValidationSchemaDefinition, resolveIsZodV4, type ZodValidationSchemaDefinition, @@ -53,6 +57,11 @@ type ZodSchemaFileToWrite = ZodSchemaFileEntry & { filePath: string; }; +const getZodSchemaImportStatement = (variant: ZodVariantOption) => + variant === 'mini' + ? `import * as zod from '${getZodImportSource(variant)}';` + : `import { z as zod } from '${getZodImportSource(variant)}';`; + interface WriteZodOutputOptions { namingConvention: NamingConvention; indexFiles: boolean; @@ -61,6 +70,7 @@ interface WriteZodOutputOptions { override: { useNamedParameters?: boolean; zod: { + variant: ZodVariantOption; version: ZodVersionOption; strict: { body: boolean; @@ -204,6 +214,7 @@ function bodyReferencesMutator( function generateZodSchemaFileContent( header: string, schemas: ZodSchemaFileEntry[], + zodVariant: ZodVariantOption, // Omit the `import { z as zod }` line when the content is concatenated into a // file that already imports zod (e.g. inline single-mode output, where the // zod client already emits `import * as zod from 'zod'`). @@ -216,7 +227,9 @@ function generateZodSchemaFileContent( ...new Set(schemas.flatMap((s) => s.importStatements ?? [])), ].toSorted(); const importBlock = [ - ...(includeZodImport ? [`import { z as zod } from 'zod';`] : []), + ...(includeZodImport + ? [getZodSchemaImportStatement(zodVariant)] + : []), ...refImports, ].join('\n'); @@ -281,6 +294,7 @@ interface RenderedReusableSchemaEntry { function renderReusableSchemaEntry( entry: ReusableSchemaEntry, context: ContextSpec, + zodVariant: ZodVariantOption, ): RenderedReusableSchemaEntry { const consts = entry.consts ? `${entry.consts}\n\n` : ''; @@ -348,7 +362,7 @@ function renderReusableSchemaEntry( return { content: `${consts}${subModelBlock}export type ${entry.name} = ${typeBody};\n\n` + - `export const ${entry.name}: zod.ZodType<${entry.name}> = ${entry.zod};\n\n` + + `export const ${entry.name}: zod.${getZodTypeName(zodVariant)}<${entry.name}> = ${entry.zod};\n\n` + `export type ${entry.name}Output = zod.output;`, extraImports, }; @@ -607,6 +621,7 @@ export function generateZodSchemasInline( output.override.zod.version, output.packageJson, ); + assertZodTarget({ variant: output.override.zod.variant, isZodV4 }); const strict = output.override.zod.strict.body; const coerce = output.override.zod.coerce.body; const schemas: ZodSchemaFileEntry[] = []; @@ -643,6 +658,9 @@ export function generateZodSchemasInline( coerce, strict, isZodV4, + undefined, + undefined, + output.override.zod.variant, ); schemas.push({ @@ -656,7 +674,12 @@ export function generateZodSchemasInline( return ''; } - return generateZodSchemaFileContent('', schemas, includeZodImport); + return generateZodSchemaFileContent( + '', + schemas, + output.override.zod.variant, + includeZodImport, + ); } function generateZodSchemasInlineReusable( @@ -670,6 +693,7 @@ function generateZodSchemasInlineReusable( output.override.zod.version, output.packageJson, ); + assertZodTarget({ variant: output.override.zod.variant, isZodV4 }); const strict = output.override.zod.strict.body; const coerce = output.override.zod.coerce.body; const context: ContextSpec = { @@ -701,6 +725,7 @@ function generateZodSchemasInlineReusable( strict, isZodV4, coerce, + variant: output.override.zod.variant, generateMeta: output.override.zod.generateMeta, paramsMutator, }); @@ -711,12 +736,18 @@ function generateZodSchemasInlineReusable( // recursive entries' TS-type references resolve in-file with no extra // imports — discard `extraImports` here. const body = rewritten - .map((entry) => renderReusableSchemaEntry(entry, context).content) + .map( + (entry) => + renderReusableSchemaEntry(entry, context, output.override.zod.variant) + .content, + ) .join('\n\n'); // Omit the zod import when concatenated into a file that already imports it // (inline single-mode output where the zod client emits `import * as zod`). - const zodImport = includeZodImport ? `import { z as zod } from 'zod';\n` : ''; + const zodImport = includeZodImport + ? `${getZodSchemaImportStatement(output.override.zod.variant)}\n` + : ''; // In split modes (`split` / `tags-split`) the inline schemas are written to // a separate `.schemas` file with no other imports, so the params-mutator // import has to be emitted here. In `single` / `tags` modes the schemas are @@ -764,6 +795,7 @@ export async function writeZodSchemas( output.override.zod.version, output.packageJson, ); + assertZodTarget({ variant: output.override.zod.variant, isZodV4 }); const strict = output.override.zod.strict.body; const coerce = output.override.zod.coerce.body; @@ -807,6 +839,9 @@ export async function writeZodSchemas( coerce, strict, isZodV4, + undefined, + undefined, + output.override.zod.variant, ); schemasToWrite.push({ @@ -820,7 +855,11 @@ export async function writeZodSchemas( const groupedSchemasToWrite = groupSchemasByFilePath(schemasToWrite); for (const schemaGroup of groupedSchemasToWrite) { - const fileContent = generateZodSchemaFileContent(header, schemaGroup); + const fileContent = generateZodSchemaFileContent( + header, + schemaGroup, + output.override.zod.variant, + ); await fs.outputFile(schemaGroup[0].filePath, fileContent); } @@ -901,6 +940,7 @@ async function writeZodSchemasReusable( strict, isZodV4, coerce, + variant: output.override.zod.variant, generateMeta: output.override.zod.generateMeta, paramsMutator, }); @@ -930,7 +970,11 @@ async function writeZodSchemasReusable( ? path.join(schemasPath, tagDir, `${fileName}${fileExtension}`) : path.join(schemasPath, `${fileName}${fileExtension}`); const importExt = getImportExtension(fileExtension, output.tsconfig); - const rendered = renderReusableSchemaEntry(entry, context); + const rendered = renderReusableSchemaEntry( + entry, + context, + output.override.zod.variant, + ); const refImports = buildSiblingImports({ usedRefs: entry.usedRefs, extraImports: rendered.extraImports, @@ -958,7 +1002,7 @@ async function writeZodSchemasReusable( ].join('\n'); const fileContent = - `${header}import { z as zod } from 'zod';\n` + + `${header}${getZodSchemaImportStatement(output.override.zod.variant)}\n` + (imports ? `${imports}\n\n` : '\n') + `${rendered.content}\n`; @@ -1012,6 +1056,7 @@ export async function writeZodSchemasFromVerbs( output.override.zod.version, output.packageJson, ); + assertZodTarget({ variant: output.override.zod.variant, isZodV4 }); const strict = output.override.zod.strict.body; const coerce = output.override.zod.coerce.body; const useReusableSchemas = @@ -1271,6 +1316,9 @@ export async function writeZodSchemasFromVerbs( coerce, strict, isZodV4, + undefined, + undefined, + output.override.zod.variant, ); // Operation schemas sit at the top of the dependency graph, so any @@ -1312,7 +1360,11 @@ export async function writeZodSchemasFromVerbs( const groupedSchemasToWrite = groupSchemasByFilePath(schemasToWrite); for (const schemaGroup of groupedSchemasToWrite) { - const fileContent = generateZodSchemaFileContent(header, schemaGroup); + const fileContent = generateZodSchemaFileContent( + header, + schemaGroup, + output.override.zod.variant, + ); await fs.outputFile(schemaGroup[0].filePath, fileContent); } diff --git a/packages/zod/src/compatible-v4.test.ts b/packages/zod/src/compatible-v4.test.ts index 7d142a2b61..bc346c5359 100644 --- a/packages/zod/src/compatible-v4.test.ts +++ b/packages/zod/src/compatible-v4.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + assertZodTarget, getLooseObjectFunctionName, getObjectFunctionName, + getZodImportSource, + getZodTypeName, getParameterFunctions, getZodDateFormat, getZodDateTimeFormat, @@ -11,6 +14,24 @@ import { resolveIsZodV4, } from './compatible-v4'; +describe('zod target helpers', () => { + it('resolves import source and recursive type for classic zod', () => { + expect(getZodImportSource('classic')).toBe('zod'); + expect(getZodTypeName('classic')).toBe('ZodType'); + }); + + it('resolves import source and recursive type for zod mini', () => { + expect(getZodImportSource('mini')).toBe('zod/mini'); + expect(getZodTypeName('mini')).toBe('ZodMiniType'); + }); + + it('rejects zod mini when the resolved target is not zod v4', () => { + expect(() => assertZodTarget({ variant: 'mini', isZodV4: false })).toThrow( + 'Zod Mini requires Zod 4 output', + ); + }); +}); + describe('isZodVersionV4', () => { it('should return false when zod is not in package.json', () => { const packageJson = { diff --git a/packages/zod/src/compatible-v4.ts b/packages/zod/src/compatible-v4.ts index 8028e42384..321313dd68 100644 --- a/packages/zod/src/compatible-v4.ts +++ b/packages/zod/src/compatible-v4.ts @@ -1,6 +1,7 @@ import { compareVersions, type PackageJson, + type ZodVariantOption, type ZodVersionOption, } from '@orval/core'; @@ -59,6 +60,26 @@ export const resolveIsZodV4 = ( return isZodVersionV4(packageJson); }; +export const assertZodTarget = ({ + variant, + isZodV4, +}: { + variant: ZodVariantOption | undefined; + isZodV4: boolean; +}) => { + if (variant === 'mini' && !isZodV4) { + throw new Error( + "Zod Mini requires Zod 4 output. Use override.zod.version: 4 or install zod@^4 when override.zod.version is 'auto'.", + ); + } +}; + +export const getZodImportSource = (variant: ZodVariantOption | undefined) => + variant === 'mini' ? 'zod/mini' : 'zod'; + +export const getZodTypeName = (variant: ZodVariantOption | undefined) => + variant === 'mini' ? 'ZodMiniType' : 'ZodType'; + export const getZodDateFormat = (isZodV4: boolean) => { return isZodV4 ? 'iso.date' : 'date'; }; diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index f818f01e3d..95ef7dc1f8 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -5,6 +5,7 @@ import { buildInlineDynamicScope, camel, type ClientBuilder, + type ClientDependenciesBuilder, type ClientGeneratorsBuilder, type ContextSpec, generateMutator, @@ -34,20 +35,30 @@ import { resolveRef, stringify, type ZodCoerceType, + type ZodVariantOption, } from '@orval/core'; import { unique } from 'remeda'; import { getLooseObjectFunctionName, getObjectFunctionName, + getZodImportSource, getParameterFunctions, getZodDateFormat, getZodDateTimeFormat, getZodTimeFormat, + assertZodTarget, resolveIsZodV4, } from './compatible-v4'; -const ZOD_DEPENDENCIES: GeneratorDependency[] = [ +export const getZodDependencies: ClientDependenciesBuilder = ( + _hasGlobalMutator, + _hasParamsSerializerOptions, + _packageJson, + _httpClient, + _hasTagsMutator, + override, +): GeneratorDependency[] => [ { exports: [ { @@ -58,12 +69,10 @@ const ZOD_DEPENDENCIES: GeneratorDependency[] = [ values: true, }, ], - dependency: 'zod', + dependency: getZodImportSource(override?.zod.variant), }, ]; -export const getZodDependencies = () => ZOD_DEPENDENCIES; - /** * values that may appear in "type". Equals SchemaObjectType */ @@ -1188,6 +1197,7 @@ export const parseZodValidationSchemaDefinition = ( isZodV4: boolean, preprocess?: GeneratorMutator, paramsInjection?: ZodParamsInjection, + variant: ZodVariantOption = 'classic', ): { zod: string; consts: string; usedRefs: Set } => { if (input.functions.length === 0) { return { zod: '', consts: '', usedRefs: new Set() }; @@ -1242,6 +1252,301 @@ export const parseZodValidationSchemaDefinition = ( return `${paramsInjection.mutator.name}(${JSON.stringify(ctx)})`; }; + const shouldCoerce = (fn: string) => + coerceTypes && + (Array.isArray(coerceTypes) + ? coerceTypes.includes(fn as ZodCoerceType) + : COERCIBLE_TYPES.has(fn)); + + const buildCombinedArgs = ( + fn: string, + args: unknown, + fieldPath: readonly string[], + ) => { + const formattedArgs = formatFunctionArgs(args); + const paramsArg = buildParamsArg(fn, fieldPath); + if ( + paramsArg && + formattedArgs && + PARAMS_MERGE_INTO_OPTIONS_VALIDATORS.has(fn) + ) { + return `{ ...${formattedArgs}, ...${paramsArg} }`; + } + if (paramsArg) { + return formattedArgs ? `${formattedArgs}, ${paramsArg}` : paramsArg; + } + return formattedArgs; + }; + + type MiniRendered = { expr: string; kind?: string }; + + const renderMiniDefinition = ( + definition: ZodValidationSchemaDefinition, + fieldPath: readonly string[] = [], + ): MiniRendered => { + let current: MiniRendered | undefined; + + const requireCurrent = (fn: string) => { + if (!current) { + throw new Error(`Cannot render zod mini ${fn} without a base schema`); + } + return current; + }; + + const renderObject = ( + objectArgs: Record, + objectType: string, + ): MiniRendered => ({ + kind: 'object', + expr: `zod.${objectType}({ +${Object.entries(objectArgs) + .map(([key, schema]) => { + const rendered = renderMiniDefinition(schema, [...fieldPath, key]); + appendConstsChunk(schema.consts.join('\n')); + const coerceArrays = + Array.isArray(coerceTypes) && + coerceTypes.includes('array' as ZodCoerceType); + if (coerceArrays && schema.functions.some(([fn]) => fn === 'array')) { + return ` "${key}": zod.pipe(zod.transform((value) => value === undefined || Array.isArray(value) ? value : [value]), ${rendered.expr})`; + } + return ` "${key}": ${rendered.expr}`; + }) + .join(',\n')} +})`, + }); + + for (let index = 0; index < definition.functions.length; index++) { + const [fn, args = ''] = definition.functions[index]; + + if (fn === 'namedRef') { + const refArgs = args as { name: string; sourceRef: string }; + usedRefs.add(refArgs.name); + current = { expr: `__REF_${refArgs.name}__`, kind: 'ref' }; + continue; + } + + if (fn === 'fileOrString') { + current = { + expr: 'zod.union([zod.instanceof(File), zod.string()])', + kind: 'union', + }; + continue; + } + + if (fn === 'allOf') { + const allOfArgs = args as ZodValidationSchemaDefinition[]; + const allAreObjects = + strict && + allOfArgs.length > 0 && + allOfArgs.every((partSchema) => { + if (partSchema.functions.length === 0) return false; + const firstFn = partSchema.functions[0][0]; + return firstFn === 'object' || firstFn === 'strictObject'; + }); + + if (allAreObjects) { + const mergedProperties: Record = + {}; + let allConsts = ''; + for (const partSchema of allOfArgs) { + if (partSchema.consts.length > 0) { + allConsts += partSchema.consts.join('\n'); + } + const objectFunctionIndex = partSchema.functions.findIndex( + ([fnName]) => fnName === 'object' || fnName === 'strictObject', + ); + if (objectFunctionIndex !== -1) { + const objectArgs = partSchema.functions[objectFunctionIndex][1]; + if (isObject(objectArgs)) { + Object.assign( + mergedProperties, + objectArgs as Record, + ); + } + } + } + appendConstsChunk(allConsts); + current = renderObject( + mergedProperties, + getObjectFunctionName(true, strict), + ); + continue; + } + + const rendered = allOfArgs.map((partSchema) => { + appendConstsChunk(partSchema.consts.join('\n')); + return renderMiniDefinition(partSchema, fieldPath).expr; + }); + if (rendered.length === 0) { + current = { expr: '' }; + continue; + } + current = { + expr: rendered.reduce((acc, value) => + acc ? `zod.intersection(${acc}, ${value})` : value, + ), + kind: 'intersection', + }; + continue; + } + + if (fn === 'oneOf' || fn === 'anyOf') { + const unionArgs = args as ZodValidationSchemaDefinition[]; + if (unionArgs.length === 1) { + appendConstsChunk(unionArgs[0].consts.join('\n')); + current = renderMiniDefinition(unionArgs[0], fieldPath); + continue; + } + current = { + expr: `zod.union([${unionArgs + .map((arg) => { + appendConstsChunk(arg.consts.join('\n')); + return renderMiniDefinition(arg, fieldPath).expr; + }) + .join(',')}])`, + kind: 'union', + }; + continue; + } + + if (fn === 'additionalProperties') { + const additionalPropertiesArgs = args as ZodValidationSchemaDefinition; + const rendered = renderMiniDefinition(additionalPropertiesArgs, fieldPath); + if (Array.isArray(additionalPropertiesArgs.consts)) { + appendConstsChunk(additionalPropertiesArgs.consts.join('\n')); + } + current = { + expr: `zod.record(zod.string(), ${rendered.expr})`, + kind: 'object', + }; + continue; + } + + if (fn === 'object' || fn === 'strictObject' || fn === 'looseObject') { + const objectType = + fn === 'looseObject' + ? 'looseObject' + : getObjectFunctionName(true, strict); + current = renderObject( + args as Record, + objectType, + ); + continue; + } + + if (fn === 'passthrough' || fn === 'strict') { + continue; + } + + if (fn === 'array') { + const arrayArgs = args as ZodValidationSchemaDefinition; + const rendered = renderMiniDefinition(arrayArgs, fieldPath); + if (isString(arrayArgs.consts)) { + appendConstsChunk(arrayArgs.consts); + } else if (Array.isArray(arrayArgs.consts)) { + appendConstsChunk(arrayArgs.consts.join('\n')); + } + current = { expr: `zod.array(${rendered.expr})`, kind: 'array' }; + continue; + } + + if (fn === 'tuple') { + const tupleItems = (args as ZodValidationSchemaDefinition[]) + .map((x) => renderMiniDefinition(x, fieldPath).expr) + .join(',\n'); + const next = definition.functions[index + 1]; + if (next?.[0] === 'rest') { + const rest = renderMiniDefinition( + next[1] as ZodValidationSchemaDefinition, + fieldPath, + ).expr; + current = { + expr: `zod.tuple([${tupleItems}], ${rest})`, + kind: 'tuple', + }; + index++; + } else { + current = { expr: `zod.tuple([${tupleItems}])`, kind: 'tuple' }; + } + continue; + } + + const combinedArgs = buildCombinedArgs(fn, args, fieldPath); + + if (fn === 'optional' || fn === 'nullable' || fn === 'nullish') { + const value = requireCurrent(fn); + current = { expr: `zod.${fn}(${value.expr})`, kind: value.kind }; + continue; + } + + if (fn === 'default') { + const value = requireCurrent(fn); + current = { + expr: `zod._default(${value.expr}, ${combinedArgs})`, + kind: value.kind, + }; + continue; + } + + if (fn === 'describe' || fn === 'meta') { + const value = requireCurrent(fn); + current = { + expr: `${value.expr}.check(zod.${fn}(${combinedArgs}))`, + kind: value.kind, + }; + continue; + } + + if ( + fn === 'min' || + fn === 'max' || + fn === 'gt' || + fn === 'lt' || + fn === 'multipleOf' || + fn === 'regex' || + fn === 'length' + ) { + const value = requireCurrent(fn); + const checkName = + fn === 'min' + ? value.kind === 'number' + ? 'gte' + : 'minLength' + : fn === 'max' + ? value.kind === 'number' + ? 'lte' + : 'maxLength' + : fn; + current = { + expr: `${value.expr}.check(zod.${checkName}(${combinedArgs}))`, + kind: value.kind, + }; + continue; + } + + if ( + (fn !== 'date' && shouldCoerce(fn)) || + (fn === 'date' && shouldCoerce(fn) && context.output.override.useDates) + ) { + current = { + expr: `zod.coerce.${fn}(${combinedArgs})`, + kind: fn, + }; + continue; + } + + current = { + expr: `zod.${fn}(${combinedArgs})`, + kind: + fn === 'enum' || fn === 'literal' || fn === 'stringFormat' + ? 'string' + : fn.split('.')[0], + }; + } + + return current ?? { expr: '' }; + }; + const parseProperty = ( property: [string, unknown], fieldPath: readonly string[] = [], @@ -1435,7 +1740,8 @@ ${Object.entries(objectArgs) // `.default()` still applies. Already-arrays are left untouched (no-op for // JSON-body arrays). const coerceArrays = - Array.isArray(coerceTypes) && coerceTypes.includes('array'); + Array.isArray(coerceTypes) && + coerceTypes.includes('array' as ZodCoerceType); if (coerceArrays && schema.functions.some(([fn]) => fn === 'array')) { return ` "${key}": zod.preprocess((value) => value === undefined || Array.isArray(value) ? value : [value], ${fieldZod})`; } @@ -1522,6 +1828,17 @@ ${Object.entries(objectArgs) appendConstsChunk(input.consts.join('\n')); + if (variant === 'mini') { + const rendered = renderMiniDefinition(input); + const value = preprocess + ? `zod.pipe(zod.transform(${preprocess.name}), ${rendered.expr})` + : rendered.expr; + if (consts.includes(',export')) { + consts = consts.replaceAll(',export', '\nexport'); + } + return { zod: value, consts, usedRefs }; + } + const schema = input.functions.map((prop) => parseProperty(prop)).join(''); const value = preprocess ? `.preprocess(${preprocess.name}, ${ @@ -2277,10 +2594,12 @@ const generateZodRoute = async ( { operationId, operationName, verb, override }: GeneratorVerbOptions, { pathRoute, context, output }: GeneratorOptions, ) => { + const zodVariant = context.output.override.zod.variant; const isZodV4 = resolveIsZodV4( context.output.override.zod.version, context.output.packageJson, ); + assertZodTarget({ variant: zodVariant, isZodV4 }); const useReusableSchemas = context.output.override.zod.generateReusableSchemas; const spec = context.spec.paths?.[pathRoute]; @@ -2376,6 +2695,7 @@ const generateZodRoute = async ( isZodV4, preprocessParams, makeParamsInjection('param', 'Params'), + zodVariant, ); const preprocessQueryParams = override.zod.preprocess?.query @@ -2396,6 +2716,7 @@ const generateZodRoute = async ( isZodV4, preprocessQueryParams, makeParamsInjection('query', 'QueryParams'), + zodVariant, ); const preprocessHeader = override.zod.preprocess?.header @@ -2416,6 +2737,7 @@ const generateZodRoute = async ( isZodV4, preprocessHeader, makeParamsInjection('header', 'Header'), + zodVariant, ); const preprocessBody = override.zod.preprocess?.body @@ -2436,6 +2758,7 @@ const generateZodRoute = async ( isZodV4, preprocessBody, makeParamsInjection('body', 'Body'), + zodVariant, ); const preprocessResponse = override.zod.preprocess?.response @@ -2460,6 +2783,7 @@ const generateZodRoute = async ( 'response', responses[index][0] ? `${responses[index][0]}Response` : 'Response', ), + zodVariant, ), ); @@ -2516,6 +2840,26 @@ const generateZodRoute = async ( : `.brand<"${name}">()` : ''; + const zodArrayWithBounds = ( + itemName: string, + rules: { min?: number; max?: number } | undefined, + ) => { + const checks = [ + ...(rules?.min ? [`zod.minLength(${rules.min})`] : []), + ...(rules?.max ? [`zod.maxLength(${rules.max})`] : []), + ]; + + if (zodVariant === 'mini') { + return `zod.array(${itemName})${ + checks.length ? `.check(${checks.join(', ')})` : '' + }`; + } + + return `zod.array(${itemName})${rules?.min ? `.min(${rules.min})` : ''}${ + rules?.max ? `.max(${rules.max})` : '' + }`; + }; + // With `generateReusableSchemas`, operations import component schemas by // their PascalCase name from a sibling schemas module. When an operation's // own pascalized wrapper name (e.g. `ListPetsResponse` from operationId @@ -2591,11 +2935,7 @@ const generateZodRoute = async ( ? [ parsedBody.isArray ? `export const ${bodyName}Item = ${inputBody.zod} -export const ${bodyName} = zod.array(${bodyName}Item)${ - parsedBody.rules?.min ? `.min(${parsedBody.rules.min})` : '' - }${ - parsedBody.rules?.max ? `.max(${parsedBody.rules.max})` : '' - }${brand(bodyName)}` +export const ${bodyName} = ${zodArrayWithBounds(bodyName + 'Item', parsedBody.rules)}${brand(bodyName)}` : `export const ${bodyName} = ${inputBody.zod}${brand(bodyName)}`, ] : []), @@ -2638,15 +2978,7 @@ export const ${bodyName} = zod.array(${bodyName}Item)${ ...(inputResponse.consts ? [inputResponse.consts] : []), parsedResponses[index].isArray ? `export const ${operationResponse}Item = ${inputResponse.zod} -export const ${operationResponse} = zod.array(${operationResponse}Item)${ - parsedResponses[index].rules?.min - ? `.min(${parsedResponses[index].rules.min})` - : '' - }${ - parsedResponses[index].rules?.max - ? `.max(${parsedResponses[index].rules.max})` - : '' - }${brand(operationResponse)}` +export const ${operationResponse} = ${zodArrayWithBounds(`${operationResponse}Item`, parsedResponses[index].rules)}${brand(operationResponse)}` : `export const ${operationResponse} = ${inputResponse.zod}${brand(operationResponse)}`, ]; }), @@ -2700,6 +3032,12 @@ const zodClientBuilder: ClientGeneratorsBuilder = { export const builder = () => () => zodClientBuilder; -export { isZodVersionV4, resolveIsZodV4 } from './compatible-v4'; +export { + assertZodTarget, + getZodImportSource, + getZodTypeName, + isZodVersionV4, + resolveIsZodV4, +} from './compatible-v4'; export default builder; diff --git a/packages/zod/src/zod.test.ts b/packages/zod/src/zod.test.ts index 8398a04fa8..18e47ad1bf 100644 --- a/packages/zod/src/zod.test.ts +++ b/packages/zod/src/zod.test.ts @@ -43,6 +43,7 @@ import { dereference, generateZod, generateZodValidationSchemaDefinition, + getZodDependencies, parseZodValidationSchemaDefinition, predefinedZodFormats, type ZodValidationSchemaDefinition, @@ -137,6 +138,148 @@ describe('parseZodValidationSchemaDefinition', () => { 'zod.object({\n "queryParams": zod.record(zod.string(), zod.unknown())\n})', ); }); + + it('renders zod mini wrappers and checks', () => { + const parseResult = parseZodValidationSchemaDefinition( + { + functions: [ + ['string', undefined], + ['min', 'nameMin'], + ['max', 'nameMax'], + ['regex', 'nameRegExp'], + ['nullable', undefined], + ['default', 'nameDefault'], + ['describe', "'Display name'"], + ], + consts: [], + }, + { + output: { + override: { + useDates: false, + }, + }, + } as ContextSpec, + false, + false, + true, + undefined, + undefined, + 'mini', + ); + + expect(parseResult.zod).toBe( + "zod._default(zod.nullable(zod.string().check(zod.minLength(nameMin)).check(zod.maxLength(nameMax)).check(zod.regex(nameRegExp))), nameDefault).check(zod.describe('Display name'))", + ); + }); + + it('renders zod mini number bounds as numeric checks', () => { + const parseResult = parseZodValidationSchemaDefinition( + { + functions: [ + ['number', undefined], + ['min', 'ageMin'], + ['max', 'ageMax'], + ['multipleOf', 'ageMultipleOf'], + ['optional', undefined], + ], + consts: [], + }, + { + output: { + override: { + useDates: false, + }, + }, + } as ContextSpec, + false, + false, + true, + undefined, + undefined, + 'mini', + ); + + expect(parseResult.zod).toBe( + 'zod.optional(zod.number().check(zod.gte(ageMin)).check(zod.lte(ageMax)).check(zod.multipleOf(ageMultipleOf)))', + ); + }); + + it('renders zod mini allOf fallback as intersections', () => { + const parseResult = parseZodValidationSchemaDefinition( + { + functions: [ + [ + 'allOf', + [ + { functions: [['string', undefined]], consts: [] }, + { functions: [['number', undefined]], consts: [] }, + ], + ], + ], + consts: [], + }, + { + output: { + override: { + useDates: false, + }, + }, + } as ContextSpec, + false, + false, + true, + undefined, + undefined, + 'mini', + ); + + expect(parseResult.zod).toBe( + 'zod.intersection(zod.string(), zod.number())', + ); + }); + + it('renders zod mini preprocess as pipe transform', () => { + const parseResult = parseZodValidationSchemaDefinition( + { functions: [['string', undefined]], consts: [] }, + { + output: { + override: { + useDates: false, + }, + }, + } as ContextSpec, + false, + false, + true, + { + name: 'stripNill', + path: './strip-nill', + default: false, + hasErrorType: false, + errorTypeName: '', + hasSecondArg: false, + hasThirdArg: false, + isHook: false, + }, + undefined, + 'mini', + ); + + expect(parseResult.zod).toBe( + 'zod.pipe(zod.transform(stripNill), zod.string())', + ); + }); +}); + +describe('getZodDependencies', () => { + it('uses zod/mini for zod mini output', () => { + expect( + getZodDependencies(false, false, undefined, undefined, false, { + zod: { variant: 'mini' }, + } as Parameters[5])[0].dependency, + ).toBe('zod/mini'); + }); }); describe('parseZodValidationSchemaDefinition with params injection', () => { From 1a4bf7221819131c3c237de6c1e9946ee172313a Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 29 Jun 2026 19:55:26 -0300 Subject: [PATCH 2/8] AI generated docs --- docs/content/docs/guides/zod.mdx | 66 ++++++++++++++++++- .../docs/reference/configuration/output.mdx | 42 ++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/guides/zod.mdx b/docs/content/docs/guides/zod.mdx index d0b9a34353..1c8868e914 100644 --- a/docs/content/docs/guides/zod.mdx +++ b/docs/content/docs/guides/zod.mdx @@ -75,6 +75,70 @@ export default defineConfig({ Pinning the version keeps output stable: the same spec produces the same schemas on every machine and in CI, independent of which `zod` version happens to be installed. +## Zod Mini + +Set `override.zod.variant` to `'mini'` to generate schemas against [Zod Mini](https://zod.dev/packages/mini): + +```ts title="orval.config.ts" +export default defineConfig({ + petstore: { + output: { + client: 'zod', + override: { + zod: { + variant: 'mini', + version: 4, + }, + }, + }, + }, +}); +``` + +Mini output imports from `zod/mini` and uses Zod Mini's functional/check-based API. It requires Zod 4; `version: 3` or `version: 'auto'` resolving to Zod 3 is rejected. + +Prefer Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes. Regular Zod puts many helpers on schema instances: + +```ts +// Regular Zod output +export const Pet = zod.object({ + name: zod.string().min(1).max(80).regex(PetNameRegExp), +}); +``` + +With Mini, checks are top-level functions. Bundlers can reason about these imports more precisely and drop helpers that are never used: + +```ts +// Zod Mini output +export const Pet = zod.object({ + name: zod + .string() + .check(zod.minLength(1), zod.maxLength(80), zod.regex(PetNameRegExp)), +}); +``` + +Tree-shaking can still fail to remove startup work if your app loads one large generated schema file. A bundler may remove the unused export binding but keep the top-level schema initializer from that loaded module: + +```ts +// source +export const UnusedPet = /*#__PURE__*/ zod.object({ name: zod.string() }); + +// possible bundled shape after the export is removed +/*#__PURE__*/ object({ name: string() }); +``` + +That means the schema is no longer importable, but its construction code may still be present and may still run when the module is evaluated. For large APIs, combine Zod Mini with split schema output or route/feature-level imports so unused schema modules are not loaded at startup. + +Use regular Zod when bundle size and startup cost are not a concern; it has the more familiar chainable API and better autocomplete ergonomics. + +One behavior difference: Zod Mini does not load the English locale by default. Configure it once if you want regular Zod's default English messages: + +```ts +import * as zod from 'zod/mini'; + +zod.config(zod.locales.en()); +``` + ## Scoping options per operation or tag Most `override.zod` settings can be applied to part of your API instead of globally, via `override.operations[operationId].zod` (a single operation) or `override.tags[tagName].zod` (every operation sharing a tag): @@ -110,7 +174,7 @@ export default defineConfig({ Per-operation and per-tag overrides accept the settings that apply to an individual schema: `strict`, `generate`, `coerce`, `preprocess`, `params`, and `useBrandedTypes`. -Output-wide settings — `version`, `dateTimeOptions`, `timeOptions`, `generateEachHttpStatus`, `generateReusableSchemas`, and `generateMeta` — only make sense for the whole output and must stay on `override.zod`. If you place one on an operation or tag it is ignored — the value from `override.zod` still applies — and Orval prints a build warning: +Output-wide settings — `variant`, `version`, `dateTimeOptions`, `timeOptions`, `generateEachHttpStatus`, `generateReusableSchemas`, and `generateMeta` — only make sense for the whole output and must stay on `override.zod`. If you place one on an operation or tag it is ignored — the value from `override.zod` still applies — and Orval prints a build warning: ``` ⚠️ override.operations.listPets.zod only supports strict, generate, coerce, preprocess, params, and useBrandedTypes. Ignoring unsupported field: zod.version. diff --git a/docs/content/docs/reference/configuration/output.mdx b/docs/content/docs/reference/configuration/output.mdx index 1a7a0b7b0e..8ea755f8b1 100644 --- a/docs/content/docs/reference/configuration/output.mdx +++ b/docs/content/docs/reference/configuration/output.mdx @@ -1379,6 +1379,7 @@ export default defineConfig({ output: { override: { zod: { + variant: 'mini', version: 4, strict: { response: true, @@ -1407,6 +1408,47 @@ export default defineConfig({ }); ``` +### variant + +**Type:** `'classic' | 'mini'` — defaults to `'classic'` + +Select the generated Zod API style. + +| Value | Output | +| ----------- | ---------------------------------------------------------------------- | +| `'classic'` | Import from `zod` and emit the regular chainable Zod API. | +| `'mini'` | Import from `zod/mini` and emit Zod Mini's functional/check-based API. | + +Zod Mini requires Zod 4 output. If `variant: 'mini'` is used with `version: 3`, or with `version: 'auto'` resolving to Zod 3, Orval throws instead of generating invalid output. + +Use Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes. Regular Zod emits chainable instance methods: + +```ts +export const Pet = zod.object({ + name: zod.string().min(1).max(80), +}); +``` + +Mini emits top-level check functions, which bundlers can tree-shake more precisely: + +```ts +export const Pet = zod.object({ + name: zod.string().check(zod.minLength(1), zod.maxLength(80)), +}); +``` + +For very large generated outputs, Mini works best together with split schema modules or route/feature-level imports. If your app loads one monolithic generated schema file, a bundler may remove an unused export binding but still keep the top-level schema initializer from that loaded module: + +```ts +// source +export const UnusedPet = /*#__PURE__*/ zod.object({ name: zod.string() }); + +// possible bundled shape +/*#__PURE__*/ object({ name: string() }); +``` + +So the safest startup optimization is: generate/load smaller schema modules, and use Zod Mini inside those modules. + ### version **Type:** `3 | 4 | 'auto'` — defaults to `'auto'` From e9084e79abb2bad78683a590a04cb3cb85353d39 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Mon, 29 Jun 2026 19:55:54 -0300 Subject: [PATCH 3/8] remove ai generated docs because they sucked --- docs/content/docs/guides/zod.mdx | 34 +------------------ .../docs/reference/configuration/output.mdx | 28 +-------------- 2 files changed, 2 insertions(+), 60 deletions(-) diff --git a/docs/content/docs/guides/zod.mdx b/docs/content/docs/guides/zod.mdx index 1c8868e914..51fc9228db 100644 --- a/docs/content/docs/guides/zod.mdx +++ b/docs/content/docs/guides/zod.mdx @@ -97,39 +97,7 @@ export default defineConfig({ Mini output imports from `zod/mini` and uses Zod Mini's functional/check-based API. It requires Zod 4; `version: 3` or `version: 'auto'` resolving to Zod 3 is rejected. -Prefer Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes. Regular Zod puts many helpers on schema instances: - -```ts -// Regular Zod output -export const Pet = zod.object({ - name: zod.string().min(1).max(80).regex(PetNameRegExp), -}); -``` - -With Mini, checks are top-level functions. Bundlers can reason about these imports more precisely and drop helpers that are never used: - -```ts -// Zod Mini output -export const Pet = zod.object({ - name: zod - .string() - .check(zod.minLength(1), zod.maxLength(80), zod.regex(PetNameRegExp)), -}); -``` - -Tree-shaking can still fail to remove startup work if your app loads one large generated schema file. A bundler may remove the unused export binding but keep the top-level schema initializer from that loaded module: - -```ts -// source -export const UnusedPet = /*#__PURE__*/ zod.object({ name: zod.string() }); - -// possible bundled shape after the export is removed -/*#__PURE__*/ object({ name: string() }); -``` - -That means the schema is no longer importable, but its construction code may still be present and may still run when the module is evaluated. For large APIs, combine Zod Mini with split schema output or route/feature-level imports so unused schema modules are not loaded at startup. - -Use regular Zod when bundle size and startup cost are not a concern; it has the more familiar chainable API and better autocomplete ergonomics. +Prefer Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes where tree-shaking and bundle size matter. Use regular Zod when those constraints are not important; it has the more familiar chainable API and better autocomplete ergonomics. One behavior difference: Zod Mini does not load the English locale by default. Configure it once if you want regular Zod's default English messages: diff --git a/docs/content/docs/reference/configuration/output.mdx b/docs/content/docs/reference/configuration/output.mdx index 8ea755f8b1..409b82911a 100644 --- a/docs/content/docs/reference/configuration/output.mdx +++ b/docs/content/docs/reference/configuration/output.mdx @@ -1421,33 +1421,7 @@ Select the generated Zod API style. Zod Mini requires Zod 4 output. If `variant: 'mini'` is used with `version: 3`, or with `version: 'auto'` resolving to Zod 3, Orval throws instead of generating invalid output. -Use Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes. Regular Zod emits chainable instance methods: - -```ts -export const Pet = zod.object({ - name: zod.string().min(1).max(80), -}); -``` - -Mini emits top-level check functions, which bundlers can tree-shake more precisely: - -```ts -export const Pet = zod.object({ - name: zod.string().check(zod.minLength(1), zod.maxLength(80)), -}); -``` - -For very large generated outputs, Mini works best together with split schema modules or route/feature-level imports. If your app loads one monolithic generated schema file, a bundler may remove an unused export binding but still keep the top-level schema initializer from that loaded module: - -```ts -// source -export const UnusedPet = /*#__PURE__*/ zod.object({ name: zod.string() }); - -// possible bundled shape -/*#__PURE__*/ object({ name: string() }); -``` - -So the safest startup optimization is: generate/load smaller schema modules, and use Zod Mini inside those modules. +Use Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes where tree-shaking and bundle size matter. ### version From 3bb35a96a3971c17ba31e8007d3af17326cd902c Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Tue, 30 Jun 2026 11:45:00 -0300 Subject: [PATCH 4/8] pure code --- packages/orval/src/reusable-schemas.test.ts | 17 ++++ packages/orval/src/reusable-schemas.ts | 6 +- packages/orval/src/write-zod-specs.test.ts | 2 +- packages/zod/src/index.ts | 95 ++++++++++++++------- packages/zod/src/zod.test.ts | 8 +- 5 files changed, 93 insertions(+), 35 deletions(-) diff --git a/packages/orval/src/reusable-schemas.test.ts b/packages/orval/src/reusable-schemas.test.ts index c4fb59019b..c53d929693 100644 --- a/packages/orval/src/reusable-schemas.test.ts +++ b/packages/orval/src/reusable-schemas.test.ts @@ -375,6 +375,23 @@ describe('rewriteReusableSchemas', () => { // Self-loop ⇒ recursive; the writer annotates `const node: zod.ZodType`. expect(result[0].isRecursive).toBe(true); }); + + it('adds pure comments to zod mini lazy self-loops', () => { + const entries = [ + { + ref: '#/components/schemas/Node', + name: 'node', + zod: 'zod.object({ child: __REF_node__ })', + consts: '', + usedRefs: new Set(['node']), + variant: 'mini' as const, + }, + ]; + const result = rewriteReusableSchemas(entries); + expect(result[0].zod).toBe( + 'zod.object({ child: /*#__PURE__*/ zod.lazy(() => node) })', + ); + }); }); describe('generateReusableSchemaSet with $dynamicRef', () => { diff --git a/packages/orval/src/reusable-schemas.ts b/packages/orval/src/reusable-schemas.ts index ad4c9f42f2..6905251c1a 100644 --- a/packages/orval/src/reusable-schemas.ts +++ b/packages/orval/src/reusable-schemas.ts @@ -128,6 +128,7 @@ export interface ReusableSchemaEntry { zod: string; consts: string; usedRefs: Set; + variant?: ZodVariantOption; /** * True when this schema references itself directly or transitively (its node * sits in a cycle: an SCC of size > 1, or a self-loop). Such a schema is @@ -239,6 +240,7 @@ export const generateReusableSchemaSet = ( zod: parsed.zod, consts: parsed.consts, usedRefs: parsed.usedRefs, + variant: options.variant, }); for (const usedName of parsed.usedRefs) { @@ -403,7 +405,9 @@ export const rewriteReusableSchemas = ( SENTINEL_PATTERN, (_match, refName: string) => { const isLazy = lazyEdges.has(edgeKey(entry.name, refName)); - return isLazy ? `zod.lazy(() => ${refName})` : refName; + return isLazy + ? `${entry.variant === 'mini' ? '/*#__PURE__*/ ' : ''}zod.lazy(() => ${refName})` + : refName; }, ); return [ diff --git a/packages/orval/src/write-zod-specs.test.ts b/packages/orval/src/write-zod-specs.test.ts index 80243f9702..11860fb820 100644 --- a/packages/orval/src/write-zod-specs.test.ts +++ b/packages/orval/src/write-zod-specs.test.ts @@ -139,7 +139,7 @@ describe('write-zod-specs regressions', () => { expect(fileContent).toContain("import * as zod from 'zod/mini';"); expect(fileContent).toContain( - 'export const RangeSchema = zod.number().check(zod.gte(RangeSchemaMin)).check(zod.lte(RangeSchemaMax))', + 'export const RangeSchema = /*#__PURE__*/ zod.number().check(/*#__PURE__*/ zod.gte(RangeSchemaMin)).check(/*#__PURE__*/ zod.lte(RangeSchemaMax))', ); await fs.remove(root); diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 95ef7dc1f8..809f6096c6 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -157,6 +157,14 @@ const COERCIBLE_TYPES = new Set([ 'date', ]); +const PURE_COMMENT = '/*#__PURE__*/ '; + +const zodMiniCall = (fn: string, args = '') => + `${PURE_COMMENT}zod.${fn}(${args})`; + +const zodMiniCoerceCall = (fn: string, args = '') => + `${PURE_COMMENT}zod.coerce.${fn}(${args})`; + export interface ZodValidationSchemaDefinition { functions: [string, unknown][]; consts: string[]; @@ -1298,7 +1306,9 @@ export const parseZodValidationSchemaDefinition = ( objectType: string, ): MiniRendered => ({ kind: 'object', - expr: `zod.${objectType}({ + expr: `${zodMiniCall( + objectType, + `{ ${Object.entries(objectArgs) .map(([key, schema]) => { const rendered = renderMiniDefinition(schema, [...fieldPath, key]); @@ -1307,12 +1317,13 @@ ${Object.entries(objectArgs) Array.isArray(coerceTypes) && coerceTypes.includes('array' as ZodCoerceType); if (coerceArrays && schema.functions.some(([fn]) => fn === 'array')) { - return ` "${key}": zod.pipe(zod.transform((value) => value === undefined || Array.isArray(value) ? value : [value]), ${rendered.expr})`; + return ` "${key}": ${zodMiniCall('pipe', `${zodMiniCall('transform', '(value) => value === undefined || Array.isArray(value) ? value : [value]')}, ${rendered.expr}`)}`; } return ` "${key}": ${rendered.expr}`; }) .join(',\n')} -})`, +}`, + )}`, }); for (let index = 0; index < definition.functions.length; index++) { @@ -1327,7 +1338,10 @@ ${Object.entries(objectArgs) if (fn === 'fileOrString') { current = { - expr: 'zod.union([zod.instanceof(File), zod.string()])', + expr: zodMiniCall( + 'union', + `[${zodMiniCall('instanceof', 'File')}, ${zodMiniCall('string')}]`, + ), kind: 'union', }; continue; @@ -1345,8 +1359,10 @@ ${Object.entries(objectArgs) }); if (allAreObjects) { - const mergedProperties: Record = - {}; + const mergedProperties: Record< + string, + ZodValidationSchemaDefinition + > = {}; let allConsts = ''; for (const partSchema of allOfArgs) { if (partSchema.consts.length > 0) { @@ -1383,7 +1399,7 @@ ${Object.entries(objectArgs) } current = { expr: rendered.reduce((acc, value) => - acc ? `zod.intersection(${acc}, ${value})` : value, + acc ? zodMiniCall('intersection', `${acc}, ${value}`) : value, ), kind: 'intersection', }; @@ -1398,12 +1414,15 @@ ${Object.entries(objectArgs) continue; } current = { - expr: `zod.union([${unionArgs - .map((arg) => { - appendConstsChunk(arg.consts.join('\n')); - return renderMiniDefinition(arg, fieldPath).expr; - }) - .join(',')}])`, + expr: zodMiniCall( + 'union', + `[${unionArgs + .map((arg) => { + appendConstsChunk(arg.consts.join('\n')); + return renderMiniDefinition(arg, fieldPath).expr; + }) + .join(',')}]`, + ), kind: 'union', }; continue; @@ -1411,12 +1430,18 @@ ${Object.entries(objectArgs) if (fn === 'additionalProperties') { const additionalPropertiesArgs = args as ZodValidationSchemaDefinition; - const rendered = renderMiniDefinition(additionalPropertiesArgs, fieldPath); + const rendered = renderMiniDefinition( + additionalPropertiesArgs, + fieldPath, + ); if (Array.isArray(additionalPropertiesArgs.consts)) { appendConstsChunk(additionalPropertiesArgs.consts.join('\n')); } current = { - expr: `zod.record(zod.string(), ${rendered.expr})`, + expr: zodMiniCall( + 'record', + `${zodMiniCall('string')}, ${rendered.expr}`, + ), kind: 'object', }; continue; @@ -1446,7 +1471,7 @@ ${Object.entries(objectArgs) } else if (Array.isArray(arrayArgs.consts)) { appendConstsChunk(arrayArgs.consts.join('\n')); } - current = { expr: `zod.array(${rendered.expr})`, kind: 'array' }; + current = { expr: zodMiniCall('array', rendered.expr), kind: 'array' }; continue; } @@ -1461,12 +1486,15 @@ ${Object.entries(objectArgs) fieldPath, ).expr; current = { - expr: `zod.tuple([${tupleItems}], ${rest})`, + expr: zodMiniCall('tuple', `[${tupleItems}], ${rest}`), kind: 'tuple', }; index++; } else { - current = { expr: `zod.tuple([${tupleItems}])`, kind: 'tuple' }; + current = { + expr: zodMiniCall('tuple', `[${tupleItems}]`), + kind: 'tuple', + }; } continue; } @@ -1475,14 +1503,14 @@ ${Object.entries(objectArgs) if (fn === 'optional' || fn === 'nullable' || fn === 'nullish') { const value = requireCurrent(fn); - current = { expr: `zod.${fn}(${value.expr})`, kind: value.kind }; + current = { expr: zodMiniCall(fn, value.expr), kind: value.kind }; continue; } if (fn === 'default') { const value = requireCurrent(fn); current = { - expr: `zod._default(${value.expr}, ${combinedArgs})`, + expr: zodMiniCall('_default', `${value.expr}, ${combinedArgs}`), kind: value.kind, }; continue; @@ -1491,7 +1519,7 @@ ${Object.entries(objectArgs) if (fn === 'describe' || fn === 'meta') { const value = requireCurrent(fn); current = { - expr: `${value.expr}.check(zod.${fn}(${combinedArgs}))`, + expr: `${value.expr}.check(${zodMiniCall(fn, combinedArgs)})`, kind: value.kind, }; continue; @@ -1518,7 +1546,7 @@ ${Object.entries(objectArgs) : 'maxLength' : fn; current = { - expr: `${value.expr}.check(zod.${checkName}(${combinedArgs}))`, + expr: `${value.expr}.check(${zodMiniCall(checkName, combinedArgs)})`, kind: value.kind, }; continue; @@ -1529,14 +1557,14 @@ ${Object.entries(objectArgs) (fn === 'date' && shouldCoerce(fn) && context.output.override.useDates) ) { current = { - expr: `zod.coerce.${fn}(${combinedArgs})`, + expr: zodMiniCoerceCall(fn, combinedArgs), kind: fn, }; continue; } current = { - expr: `zod.${fn}(${combinedArgs})`, + expr: zodMiniCall(fn, combinedArgs), kind: fn === 'enum' || fn === 'literal' || fn === 'stringFormat' ? 'string' @@ -1831,7 +1859,10 @@ ${Object.entries(objectArgs) if (variant === 'mini') { const rendered = renderMiniDefinition(input); const value = preprocess - ? `zod.pipe(zod.transform(${preprocess.name}), ${rendered.expr})` + ? zodMiniCall( + 'pipe', + `${zodMiniCall('transform', preprocess.name)}, ${rendered.expr}`, + ) : rendered.expr; if (consts.includes(',export')) { consts = consts.replaceAll(',export', '\nexport'); @@ -2845,12 +2876,12 @@ const generateZodRoute = async ( rules: { min?: number; max?: number } | undefined, ) => { const checks = [ - ...(rules?.min ? [`zod.minLength(${rules.min})`] : []), - ...(rules?.max ? [`zod.maxLength(${rules.max})`] : []), + ...(rules?.min ? [zodMiniCall('minLength', `${rules.min}`)] : []), + ...(rules?.max ? [zodMiniCall('maxLength', `${rules.max}`)] : []), ]; if (zodVariant === 'mini') { - return `zod.array(${itemName})${ + return `${zodMiniCall('array', itemName)}${ checks.length ? `.check(${checks.join(', ')})` : '' }`; } @@ -2967,7 +2998,13 @@ export const ${bodyName} = ${zodArrayWithBounds(bodyName + 'Item', parsedBody.ru specResponseKeys.has('2xx'); isNoContent = !hasStandardSuccess; } - const noContentSchema = isNoContent ? 'zod.void()' : 'zod.unknown()'; + const noContentSchema = isNoContent + ? zodVariant === 'mini' + ? zodMiniCall('void') + : 'zod.void()' + : zodVariant === 'mini' + ? zodMiniCall('unknown') + : 'zod.unknown()'; return [ `export const ${operationResponse} = ${noContentSchema}${brand(operationResponse)}`, diff --git a/packages/zod/src/zod.test.ts b/packages/zod/src/zod.test.ts index 18e47ad1bf..a92cca953b 100644 --- a/packages/zod/src/zod.test.ts +++ b/packages/zod/src/zod.test.ts @@ -169,7 +169,7 @@ describe('parseZodValidationSchemaDefinition', () => { ); expect(parseResult.zod).toBe( - "zod._default(zod.nullable(zod.string().check(zod.minLength(nameMin)).check(zod.maxLength(nameMax)).check(zod.regex(nameRegExp))), nameDefault).check(zod.describe('Display name'))", + "/*#__PURE__*/ zod._default(/*#__PURE__*/ zod.nullable(/*#__PURE__*/ zod.string().check(/*#__PURE__*/ zod.minLength(nameMin)).check(/*#__PURE__*/ zod.maxLength(nameMax)).check(/*#__PURE__*/ zod.regex(nameRegExp))), nameDefault).check(/*#__PURE__*/ zod.describe('Display name'))", ); }); @@ -201,7 +201,7 @@ describe('parseZodValidationSchemaDefinition', () => { ); expect(parseResult.zod).toBe( - 'zod.optional(zod.number().check(zod.gte(ageMin)).check(zod.lte(ageMax)).check(zod.multipleOf(ageMultipleOf)))', + '/*#__PURE__*/ zod.optional(/*#__PURE__*/ zod.number().check(/*#__PURE__*/ zod.gte(ageMin)).check(/*#__PURE__*/ zod.lte(ageMax)).check(/*#__PURE__*/ zod.multipleOf(ageMultipleOf)))', ); }); @@ -235,7 +235,7 @@ describe('parseZodValidationSchemaDefinition', () => { ); expect(parseResult.zod).toBe( - 'zod.intersection(zod.string(), zod.number())', + '/*#__PURE__*/ zod.intersection(/*#__PURE__*/ zod.string(), /*#__PURE__*/ zod.number())', ); }); @@ -267,7 +267,7 @@ describe('parseZodValidationSchemaDefinition', () => { ); expect(parseResult.zod).toBe( - 'zod.pipe(zod.transform(stripNill), zod.string())', + '/*#__PURE__*/ zod.pipe(/*#__PURE__*/ zod.transform(stripNill), /*#__PURE__*/ zod.string())', ); }); }); From 7c5fc3604a3ad9b95536a67f6ff893245668ecfb Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Tue, 30 Jun 2026 11:50:38 -0300 Subject: [PATCH 5/8] code --- packages/orval/src/write-zod-specs.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/orval/src/write-zod-specs.ts b/packages/orval/src/write-zod-specs.ts index d8fd3b4e3c..eb8d954cf5 100644 --- a/packages/orval/src/write-zod-specs.ts +++ b/packages/orval/src/write-zod-specs.ts @@ -227,9 +227,7 @@ function generateZodSchemaFileContent( ...new Set(schemas.flatMap((s) => s.importStatements ?? [])), ].toSorted(); const importBlock = [ - ...(includeZodImport - ? [getZodSchemaImportStatement(zodVariant)] - : []), + ...(includeZodImport ? [getZodSchemaImportStatement(zodVariant)] : []), ...refImports, ].join('\n'); From 6fb32d36fb45216310d6555660d44abfd0caf1fa Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Tue, 30 Jun 2026 11:58:51 -0300 Subject: [PATCH 6/8] docs and lint --- docs/content/docs/guides/zod.mdx | 57 +++++++++++++++++-- .../docs/reference/configuration/output.mdx | 6 +- packages/angular/src/http-client.test.ts | 1 + packages/angular/src/http-resource.test.ts | 1 + packages/core/src/test-utils/context.ts | 1 + .../mock/src/faker/getters/combine.test.ts | 1 + packages/solid-start/src/index.test.ts | 1 + 7 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/guides/zod.mdx b/docs/content/docs/guides/zod.mdx index 51fc9228db..e6c58d76b2 100644 --- a/docs/content/docs/guides/zod.mdx +++ b/docs/content/docs/guides/zod.mdx @@ -18,6 +18,13 @@ export default defineConfig({ client: 'zod', mode: 'single', target: './src/api/schemas', + override: { + zod: { + // Prefer Mini for more tree-shakeable generated schemas. + variant: 'mini', + version: 4, + }, + }, }, input: { target: './petstore.yaml', @@ -31,13 +38,50 @@ export default defineConfig({ Orval generates a Zod schema for each model in your OpenAPI specification: ```ts -export const createPetsBody = zod.object({ - id: zod.number(), - name: zod.string(), - tag: zod.string().optional(), +export const createPetsBody = /*#__PURE__*/ zod.object({ + id: /*#__PURE__*/ zod.number(), + name: /*#__PURE__*/ zod.string(), + tag: /*#__PURE__*/ zod.optional(/*#__PURE__*/ zod.string()), }); ``` +## Tree Shaking + +Prefer `variant: 'mini'` when startup time, memory usage, or bundle size matter. Zod Mini uses top-level helpers, so generated validators can be dropped more reliably when their exports are unused: + +```ts +// Regular Zod +export const User = zod.object({ + email: zod.email().min(5).max(255), +}); + +// Zod Mini +export const User = /*#__PURE__*/ zod.object({ + email: /*#__PURE__*/ zod + .email() + .check(/*#__PURE__*/ zod.minLength(5)) + .check(/*#__PURE__*/ zod.maxLength(255)), +}); +``` + +When `User` is unused, a bundled regular Zod output can still keep the schema initializer as top-level work even if nothing uses it: + +```js +// possible bundled regular Zod output (no export const User, but the zod call is still there) +object({ + email: email().min(userEmailMin).max(userEmailMax), +}); +``` + +With Zod Mini + pure annotations, the unused schema can disappear entirely: + +```js +// possible bundled Zod Mini output +// no User schema code emitted at all +``` + +In large generated clients this can remove unused schema initializers from the final bundle. In real builds this can mean hundreds of kilobytes less generated validation code loaded at startup. + ## Zod version Orval uses **Zod 4** as its output baseline (`z.strictObject`, `z.looseObject`, `z.iso.datetime()`, `.meta()`, …), while projects still on Zod 3 are fully supported. The emitted syntax follows whichever `zod` major your project resolves. @@ -56,6 +100,7 @@ export default defineConfig({ target: './src/api/schemas', override: { zod: { + variant: 'mini', version: 4, // 3 | 4 | 'auto' }, }, @@ -118,6 +163,8 @@ export default defineConfig({ client: 'zod', override: { zod: { + variant: 'mini', + version: 4, strict: { response: true }, // applies everywhere }, operations: { @@ -163,7 +210,7 @@ const parsedPet = createPetsBody.parse(pet); ### Type Inference ```ts -import type { z } from 'zod'; +import type { z } from 'zod/mini'; import { createPetsBody } from './src/api/schemas'; type Pet = z.infer; diff --git a/docs/content/docs/reference/configuration/output.mdx b/docs/content/docs/reference/configuration/output.mdx index 409b82911a..073d6b7027 100644 --- a/docs/content/docs/reference/configuration/output.mdx +++ b/docs/content/docs/reference/configuration/output.mdx @@ -1416,12 +1416,12 @@ Select the generated Zod API style. | Value | Output | | ----------- | ---------------------------------------------------------------------- | -| `'classic'` | Import from `zod` and emit the regular chainable Zod API. | | `'mini'` | Import from `zod/mini` and emit Zod Mini's functional/check-based API. | +| `'classic'` | Import from `zod` and emit the regular chainable Zod API. | -Zod Mini requires Zod 4 output. If `variant: 'mini'` is used with `version: 3`, or with `version: 'auto'` resolving to Zod 3, Orval throws instead of generating invalid output. +`'classic'` is the default to avoid changing existing projects. Prefer `'mini'` when startup time, memory usage, or bundle size matter. -Use Zod Mini when generated schemas are bundled into frontend apps, Cloudflare Workers, or other startup-sensitive runtimes where tree-shaking and bundle size matter. +Zod Mini requires Zod 4 output. If `variant: 'mini'` is used with `version: 3`, or with `version: 'auto'` resolving to Zod 3, Orval throws instead of generating invalid output. ### version diff --git a/packages/angular/src/http-client.test.ts b/packages/angular/src/http-client.test.ts index 7a2888f4d0..db19fe3a6e 100644 --- a/packages/angular/src/http-client.test.ts +++ b/packages/angular/src/http-client.test.ts @@ -75,6 +75,7 @@ const createOutput = ( swr: {}, zod: { version: 'auto', + variant: 'classic', strict: { param: false, query: false, diff --git a/packages/angular/src/http-resource.test.ts b/packages/angular/src/http-resource.test.ts index efdc62bff4..d084c21354 100644 --- a/packages/angular/src/http-resource.test.ts +++ b/packages/angular/src/http-resource.test.ts @@ -85,6 +85,7 @@ const createOutput = ( swr: {}, zod: { version: 'auto', + variant: 'classic', strict: { param: false, query: false, diff --git a/packages/core/src/test-utils/context.ts b/packages/core/src/test-utils/context.ts index e93fc0beb9..dd19b8b3bb 100644 --- a/packages/core/src/test-utils/context.ts +++ b/packages/core/src/test-utils/context.ts @@ -106,6 +106,7 @@ export function createTestContextSpec({ swr: {}, zod: { version: 'auto', + variant: 'classic', strict: { param: false, query: false, diff --git a/packages/mock/src/faker/getters/combine.test.ts b/packages/mock/src/faker/getters/combine.test.ts index 16f9ad84f0..f15efa08fd 100644 --- a/packages/mock/src/faker/getters/combine.test.ts +++ b/packages/mock/src/faker/getters/combine.test.ts @@ -102,6 +102,7 @@ function createMockContext(): ContextSpec { swr: {}, zod: { version: 'auto', + variant: 'classic', strict: { param: false, query: false, diff --git a/packages/solid-start/src/index.test.ts b/packages/solid-start/src/index.test.ts index ae72eee0eb..32982c27ab 100644 --- a/packages/solid-start/src/index.test.ts +++ b/packages/solid-start/src/index.test.ts @@ -107,6 +107,7 @@ function makeOutput(useDates = false): ContextSpec['output'] { swr: {}, zod: { version: 'auto', + variant: 'classic', strict: { param: false, query: false, From edf17d7ab0130d8fe682fffb3fb892a98540b5c7 Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Tue, 30 Jun 2026 12:03:04 -0300 Subject: [PATCH 7/8] fix code review --- packages/orval/src/write-zod-specs.ts | 1 + packages/zod/src/index.ts | 25 +++++----- packages/zod/src/zod.test.ts | 71 +++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/packages/orval/src/write-zod-specs.ts b/packages/orval/src/write-zod-specs.ts index eb8d954cf5..d02aa91f51 100644 --- a/packages/orval/src/write-zod-specs.ts +++ b/packages/orval/src/write-zod-specs.ts @@ -905,6 +905,7 @@ async function writeZodSchemasReusable( output.override.zod.version, output.packageJson, ); + assertZodTarget({ variant: output.override.zod.variant, isZodV4 }); const strict = output.override.zod.strict.body; const coerce = output.override.zod.coerce.body; const context: ContextSpec = { diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 809f6096c6..e1e8dd1079 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1363,10 +1363,10 @@ ${Object.entries(objectArgs) string, ZodValidationSchemaDefinition > = {}; - let allConsts = ''; + const allConsts: string[] = []; for (const partSchema of allOfArgs) { if (partSchema.consts.length > 0) { - allConsts += partSchema.consts.join('\n'); + allConsts.push(partSchema.consts.join('\n')); } const objectFunctionIndex = partSchema.functions.findIndex( ([fnName]) => fnName === 'object' || fnName === 'strictObject', @@ -1381,7 +1381,7 @@ ${Object.entries(objectArgs) } } } - appendConstsChunk(allConsts); + appendConstsChunk(allConsts.join('\n')); current = renderObject( mergedProperties, getObjectFunctionName(true, strict), @@ -1477,14 +1477,17 @@ ${Object.entries(objectArgs) if (fn === 'tuple') { const tupleItems = (args as ZodValidationSchemaDefinition[]) - .map((x) => renderMiniDefinition(x, fieldPath).expr) + .map((x) => { + const rendered = renderMiniDefinition(x, fieldPath); + appendConstsChunk(x.consts.join('\n')); + return rendered.expr; + }) .join(',\n'); const next = definition.functions[index + 1]; if (next?.[0] === 'rest') { - const rest = renderMiniDefinition( - next[1] as ZodValidationSchemaDefinition, - fieldPath, - ).expr; + const restDefinition = next[1] as ZodValidationSchemaDefinition; + const rest = renderMiniDefinition(restDefinition, fieldPath).expr; + appendConstsChunk(restDefinition.consts.join('\n')); current = { expr: zodMiniCall('tuple', `[${tupleItems}], ${rest}`), kind: 'tuple', @@ -2881,9 +2884,9 @@ const generateZodRoute = async ( ]; if (zodVariant === 'mini') { - return `${zodMiniCall('array', itemName)}${ - checks.length ? `.check(${checks.join(', ')})` : '' - }`; + return `${zodMiniCall('array', itemName)}${checks + .map((check) => `.check(${check})`) + .join('')}`; } return `zod.array(${itemName})${rules?.min ? `.min(${rules.min})` : ''}${ diff --git a/packages/zod/src/zod.test.ts b/packages/zod/src/zod.test.ts index a92cca953b..55c360704f 100644 --- a/packages/zod/src/zod.test.ts +++ b/packages/zod/src/zod.test.ts @@ -239,6 +239,77 @@ describe('parseZodValidationSchemaDefinition', () => { ); }); + it('renders zod mini allOf merged object consts with separators', () => { + const parseResult = parseZodValidationSchemaDefinition( + { + functions: [ + [ + 'allOf', + [ + { + functions: [['object', {}]], + consts: ['export const First = 1;'], + }, + { + functions: [['object', {}]], + consts: ['export const Second = 2;'], + }, + ], + ], + ], + consts: [], + }, + { output: { override: { useDates: false } } } as ContextSpec, + false, + true, + true, + undefined, + undefined, + 'mini', + ); + + expect(parseResult.consts).toBe( + 'export const First = 1;\nexport const Second = 2;', + ); + }); + + it('renders zod mini tuple and rest consts', () => { + const parseResult = parseZodValidationSchemaDefinition( + { + functions: [ + [ + 'tuple', + [ + { + functions: [['string', undefined]], + consts: ['export const TupleItemMin = 1;'], + }, + ], + ], + [ + 'rest', + { + functions: [['number', undefined]], + consts: ['export const TupleRestMin = 2;'], + }, + ], + ], + consts: [], + }, + { output: { override: { useDates: false } } } as ContextSpec, + false, + false, + true, + undefined, + undefined, + 'mini', + ); + + expect(parseResult.consts).toBe( + 'export const TupleItemMin = 1;\nexport const TupleRestMin = 2;', + ); + }); + it('renders zod mini preprocess as pipe transform', () => { const parseResult = parseZodValidationSchemaDefinition( { functions: [['string', undefined]], consts: [] }, From 99ddae0babaaffeb9670986ba30b2d02ae858fbf Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Tue, 30 Jun 2026 12:06:32 -0300 Subject: [PATCH 8/8] forgot to commit this file --- packages/zod/src/zod.test.ts | 70 ++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/zod/src/zod.test.ts b/packages/zod/src/zod.test.ts index 55c360704f..2b8f5dcbcc 100644 --- a/packages/zod/src/zod.test.ts +++ b/packages/zod/src/zod.test.ts @@ -9725,6 +9725,76 @@ describe('generateZod (useBrandedTypes)', () => { ); }); + it('chains zod mini array response bounds checks', async () => { + const arrayResponseApiSchema = { + pathRoute: '/cats', + context: { + spec: { + paths: { + '/cats': { + get: { + operationId: 'xyz', + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'array', + minItems: 1, + maxItems: 2, + items: { + type: 'object', + properties: { + id: { type: 'number' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + output: { + override: { + zod: { + variant: 'mini', + generateEachHttpStatus: false, + }, + }, + }, + }, + } as unknown as GeneratorOptions; + + const result = await generateZod( + { + pathRoute: '/cats', + verb: 'get', + operationName: 'test', + override: { + zod: { + ...brandedZodOverrideDisabled.zod, + generate: { + param: false, + body: false, + response: true, + query: false, + header: false, + }, + }, + }, + } as unknown as Parameters[0], + withOutputZodVersion(arrayResponseApiSchema, 4), + testOutput, + ); + + expect(result.implementation).toContain( + 'export const TestResponse = /*#__PURE__*/ zod.array(TestResponseItem).check(/*#__PURE__*/ zod.minLength(1)).check(/*#__PURE__*/ zod.maxLength(2))', + ); + }); + // Group 4: Combination with other options it('appends .brand() after .strict() when both strict and useBrandedTypes are enabled (zod v3)', async () => {