diff --git a/docs/content/docs/guides/zod.mdx b/docs/content/docs/guides/zod.mdx index d0b9a34353..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' }, }, @@ -75,6 +120,38 @@ 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 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: + +```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): @@ -86,6 +163,8 @@ export default defineConfig({ client: 'zod', override: { zod: { + variant: 'mini', + version: 4, strict: { response: true }, // applies everywhere }, operations: { @@ -110,7 +189,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. @@ -131,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 1a7a0b7b0e..073d6b7027 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,21 @@ export default defineConfig({ }); ``` +### variant + +**Type:** `'classic' | 'mini'` — defaults to `'classic'` + +Select the generated Zod API style. + +| Value | Output | +| ----------- | ---------------------------------------------------------------------- | +| `'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. | + +`'classic'` is the default to avoid changing existing projects. Prefer `'mini'` when startup time, memory usage, or 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 **Type:** `3 | 4 | 'auto'` — defaults to `'auto'` 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/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/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/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 6f2a1b20d3..6905251c1a 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 { @@ -127,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 @@ -141,6 +143,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 +231,7 @@ export const generateReusableSchemaSet = ( schemaName: name, } : undefined, + options.variant, ); entries.push({ @@ -236,6 +240,7 @@ export const generateReusableSchemaSet = ( zod: parsed.zod, consts: parsed.consts, usedRefs: parsed.usedRefs, + variant: options.variant, }); for (const usedName of parsed.usedRefs) { @@ -400,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/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..11860fb820 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 = /*#__PURE__*/ zod.number().check(/*#__PURE__*/ zod.gte(RangeSchemaMin)).check(/*#__PURE__*/ 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..d02aa91f51 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,7 @@ 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 +292,7 @@ interface RenderedReusableSchemaEntry { function renderReusableSchemaEntry( entry: ReusableSchemaEntry, context: ContextSpec, + zodVariant: ZodVariantOption, ): RenderedReusableSchemaEntry { const consts = entry.consts ? `${entry.consts}\n\n` : ''; @@ -348,7 +360,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 +619,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 +656,9 @@ export function generateZodSchemasInline( coerce, strict, isZodV4, + undefined, + undefined, + output.override.zod.variant, ); schemas.push({ @@ -656,7 +672,12 @@ export function generateZodSchemasInline( return ''; } - return generateZodSchemaFileContent('', schemas, includeZodImport); + return generateZodSchemaFileContent( + '', + schemas, + output.override.zod.variant, + includeZodImport, + ); } function generateZodSchemasInlineReusable( @@ -670,6 +691,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 +723,7 @@ function generateZodSchemasInlineReusable( strict, isZodV4, coerce, + variant: output.override.zod.variant, generateMeta: output.override.zod.generateMeta, paramsMutator, }); @@ -711,12 +734,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 +793,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 +837,9 @@ export async function writeZodSchemas( coerce, strict, isZodV4, + undefined, + undefined, + output.override.zod.variant, ); schemasToWrite.push({ @@ -820,7 +853,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); } @@ -868,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 = { @@ -901,6 +939,7 @@ async function writeZodSchemasReusable( strict, isZodV4, coerce, + variant: output.override.zod.variant, generateMeta: output.override.zod.generateMeta, paramsMutator, }); @@ -930,7 +969,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 +1001,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 +1055,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 +1315,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 +1359,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/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, 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..e1e8dd1079 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 */ @@ -148,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[]; @@ -1188,6 +1205,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 +1260,324 @@ 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: `${zodMiniCall( + 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}": ${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++) { + 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: zodMiniCall( + 'union', + `[${zodMiniCall('instanceof', 'File')}, ${zodMiniCall('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< + string, + ZodValidationSchemaDefinition + > = {}; + const allConsts: string[] = []; + for (const partSchema of allOfArgs) { + if (partSchema.consts.length > 0) { + allConsts.push(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.join('\n')); + 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 ? zodMiniCall('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: zodMiniCall( + '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: zodMiniCall( + 'record', + `${zodMiniCall('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: zodMiniCall('array', rendered.expr), kind: 'array' }; + continue; + } + + if (fn === 'tuple') { + const tupleItems = (args as ZodValidationSchemaDefinition[]) + .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 restDefinition = next[1] as ZodValidationSchemaDefinition; + const rest = renderMiniDefinition(restDefinition, fieldPath).expr; + appendConstsChunk(restDefinition.consts.join('\n')); + current = { + expr: zodMiniCall('tuple', `[${tupleItems}], ${rest}`), + kind: 'tuple', + }; + index++; + } else { + current = { + expr: zodMiniCall('tuple', `[${tupleItems}]`), + kind: 'tuple', + }; + } + continue; + } + + const combinedArgs = buildCombinedArgs(fn, args, fieldPath); + + if (fn === 'optional' || fn === 'nullable' || fn === 'nullish') { + const value = requireCurrent(fn); + current = { expr: zodMiniCall(fn, value.expr), kind: value.kind }; + continue; + } + + if (fn === 'default') { + const value = requireCurrent(fn); + current = { + expr: zodMiniCall('_default', `${value.expr}, ${combinedArgs}`), + kind: value.kind, + }; + continue; + } + + if (fn === 'describe' || fn === 'meta') { + const value = requireCurrent(fn); + current = { + expr: `${value.expr}.check(${zodMiniCall(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(${zodMiniCall(checkName, combinedArgs)})`, + kind: value.kind, + }; + continue; + } + + if ( + (fn !== 'date' && shouldCoerce(fn)) || + (fn === 'date' && shouldCoerce(fn) && context.output.override.useDates) + ) { + current = { + expr: zodMiniCoerceCall(fn, combinedArgs), + kind: fn, + }; + continue; + } + + current = { + expr: zodMiniCall(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 +1771,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 +1859,20 @@ ${Object.entries(objectArgs) appendConstsChunk(input.consts.join('\n')); + if (variant === 'mini') { + const rendered = renderMiniDefinition(input); + const value = preprocess + ? zodMiniCall( + 'pipe', + `${zodMiniCall('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 +2628,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 +2729,7 @@ const generateZodRoute = async ( isZodV4, preprocessParams, makeParamsInjection('param', 'Params'), + zodVariant, ); const preprocessQueryParams = override.zod.preprocess?.query @@ -2396,6 +2750,7 @@ const generateZodRoute = async ( isZodV4, preprocessQueryParams, makeParamsInjection('query', 'QueryParams'), + zodVariant, ); const preprocessHeader = override.zod.preprocess?.header @@ -2416,6 +2771,7 @@ const generateZodRoute = async ( isZodV4, preprocessHeader, makeParamsInjection('header', 'Header'), + zodVariant, ); const preprocessBody = override.zod.preprocess?.body @@ -2436,6 +2792,7 @@ const generateZodRoute = async ( isZodV4, preprocessBody, makeParamsInjection('body', 'Body'), + zodVariant, ); const preprocessResponse = override.zod.preprocess?.response @@ -2460,6 +2817,7 @@ const generateZodRoute = async ( 'response', responses[index][0] ? `${responses[index][0]}Response` : 'Response', ), + zodVariant, ), ); @@ -2516,6 +2874,26 @@ const generateZodRoute = async ( : `.brand<"${name}">()` : ''; + const zodArrayWithBounds = ( + itemName: string, + rules: { min?: number; max?: number } | undefined, + ) => { + const checks = [ + ...(rules?.min ? [zodMiniCall('minLength', `${rules.min}`)] : []), + ...(rules?.max ? [zodMiniCall('maxLength', `${rules.max}`)] : []), + ]; + + if (zodVariant === 'mini') { + return `${zodMiniCall('array', itemName)}${checks + .map((check) => `.check(${check})`) + .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 +2969,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)}`, ] : []), @@ -2627,7 +3001,13 @@ export const ${bodyName} = zod.array(${bodyName}Item)${ 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)}`, @@ -2638,15 +3018,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 +3072,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..2b8f5dcbcc 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,219 @@ 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( + "/*#__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'))", + ); + }); + + 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( + '/*#__PURE__*/ zod.optional(/*#__PURE__*/ zod.number().check(/*#__PURE__*/ zod.gte(ageMin)).check(/*#__PURE__*/ zod.lte(ageMax)).check(/*#__PURE__*/ 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( + '/*#__PURE__*/ zod.intersection(/*#__PURE__*/ zod.string(), /*#__PURE__*/ zod.number())', + ); + }); + + 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: [] }, + { + 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( + '/*#__PURE__*/ zod.pipe(/*#__PURE__*/ zod.transform(stripNill), /*#__PURE__*/ 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', () => { @@ -9511,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 () => {