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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions docs/content/docs/guides/zod.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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.
Expand All @@ -56,6 +100,7 @@ export default defineConfig({
target: './src/api/schemas',
override: {
zod: {
variant: 'mini',
version: 4, // 3 | 4 | 'auto'
Comment thread
arthurfiorette marked this conversation as resolved.
},
},
Expand All @@ -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):
Expand All @@ -86,6 +163,8 @@ export default defineConfig({
client: 'zod',
override: {
zod: {
variant: 'mini',
version: 4,
strict: { response: true }, // applies everywhere
},
operations: {
Expand All @@ -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.
Expand All @@ -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<typeof createPetsBody>;
Comment thread
arthurfiorette marked this conversation as resolved.
Expand Down
16 changes: 16 additions & 0 deletions docs/content/docs/reference/configuration/output.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,7 @@ export default defineConfig({
output: {
override: {
zod: {
variant: 'mini',
version: 4,
strict: {
response: true,
Expand Down Expand Up @@ -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'`
Expand Down
1 change: 1 addition & 0 deletions packages/angular/src/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const createOutput = (
swr: {},
zod: {
version: 'auto',
variant: 'classic',
strict: {
param: false,
query: false,
Expand Down
1 change: 1 addition & 0 deletions packages/angular/src/http-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const createOutput = (
swr: {},
zod: {
version: 'auto',
variant: 'classic',
strict: {
param: false,
query: false,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/test-utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function createTestContextSpec({
swr: {},
zod: {
version: 'auto',
variant: 'classic',
strict: {
param: false,
query: false,
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,8 @@ export interface ZodTimeOptions {
*/
export type ZodVersionOption = 3 | 4 | 'auto';

export type ZodVariantOption = 'classic' | 'mini';

interface BaseZodOptions {
strict?: {
param?: boolean;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -905,6 +913,7 @@ export type ZodCoerceType =
| 'array';

export interface NormalizedZodOptions {
variant: ZodVariantOption;
version: ZodVersionOption;
strict: {
param: boolean;
Expand Down
13 changes: 10 additions & 3 deletions packages/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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}`);

Expand Down
11 changes: 9 additions & 2 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]),
Expand Down Expand Up @@ -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}`);

Expand Down
1 change: 1 addition & 0 deletions packages/mock/src/faker/getters/combine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ function createMockContext(): ContextSpec {
swr: {},
zod: {
version: 'auto',
variant: 'classic',
strict: {
param: false,
query: false,
Expand Down
17 changes: 17 additions & 0 deletions packages/orval/src/reusable-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,23 @@ describe('rewriteReusableSchemas', () => {
// Self-loop ⇒ recursive; the writer annotates `const node: zod.ZodType<node>`.
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', () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/orval/src/reusable-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
GeneratorMutator,
OpenApiSchemaObject,
ZodCoerceType,
ZodVariantOption,
} from '@orval/core';
import { buildDynamicScope, getRefInfo } from '@orval/core';
import {
Expand Down Expand Up @@ -127,6 +128,7 @@ export interface ReusableSchemaEntry {
zod: string;
consts: string;
usedRefs: Set<string>;
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
Expand All @@ -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;
Expand Down Expand Up @@ -228,6 +231,7 @@ export const generateReusableSchemaSet = (
schemaName: name,
}
: undefined,
options.variant,
);

entries.push({
Expand All @@ -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) {
Expand Down Expand Up @@ -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 [
Expand Down
Loading
Loading