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
59 changes: 59 additions & 0 deletions docs/content/docs/reference/configuration/output.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,65 @@ Control which schemas are generated.

Add preprocess functions to schemas.

### params

**Type:** [`Mutator`](#mutator)

Inject a Zod `params` argument (e.g. `{ error: ... }`) into every generated validator. The referenced function is called once per validator at schema construction time and receives codegen-time context (operation, location, schema name, field path, validator name). Whatever it returns is passed as the trailing argument of the call.

Useful for i18n error keys, branded error messages, or any field-aware customisation that Zod's global error map cannot disambiguate on its own (because `issue.path` does not carry operation/schema identity).

```ts title="orval.config.ts"
export default defineConfig({
petstore: {
output: {
override: {
zod: {
params: { path: './zod-params.ts', name: 'zodParams' },
},
},
},
},
});
```

```ts title="zod-params.ts"
import type { ZodParamsContext } from 'orval';
import { i18n } from './i18n';

export const zodParams = (ctx: ZodParamsContext) => ({
error: (issue: { input: unknown; path: PropertyKey[] }) =>
i18n.t(
`errors.${ctx.schemaName}.${ctx.fieldPath.join('.')}.${ctx.validator}`,
{ value: issue.input },
),
});
```

The `'schema'` location is used for shared component schemas emitted under [`generateReusableSchemas`](#generatereusableschemas). Component schemas have no single owning operation, so `operationId` is the empty string in that case — branch on `ctx.location === 'schema'` if your error keys need to fall back to a schema-only namespace.

Generated output (excerpt):

```ts
import { zodParams } from './zod-params';

export const CreateUserBody = zod.object({
email: zod
.string(zodParams({ operationId: 'createUser', location: 'body', schemaName: 'CreateUserBody', fieldPath: ['email'], validator: 'string' }))
.email(zodParams({ operationId: 'createUser', location: 'body', schemaName: 'CreateUserBody', fieldPath: ['email'], validator: 'email' })),
});
```

Injection scope:

- Applied to base types (`string`, `number`, `boolean`, `bigint`, `date`, `integer`), constraints (`min`, `max`, `gt`, `lt`, `multipleOf`, `regex`, `length`), formats (`email`, `url`, `uuid`, `hostname`, `datetime`, `time`), and `literal`, `enum`, `instanceof`, `stringFormat`.
- Skipped on modifiers (`optional`, `nullable`, `nullish`, `default`, `describe`) and structural calls (`object`, `array`, `tuple`, `union`, `rest`, `passthrough`, `strict`).
- `fieldPath` only includes object property names, mirroring Zod's own `issue.path`. Array indices and tuple positions are not appended — the inner element of `{ tags: array<string> }` and a top-level `tags: string` both see `fieldPath: ['tags']`. Use the `validator` field to distinguish a container (`'array'`, `'tuple'`) from its element (`'string'`, `'number'`).

For static messages, return an object with a string `error`: `return { error: 'My message' }`. The function may return `undefined` to fall back to Zod defaults for a specific call.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The { error } shape is zod v4-only. orval supports both v3 and v4 and this injection runs on both paths, but I checked against zod 3.25.76 / 4.3.6: on v3 { error } is silently ignored — no build- or parse-time error, the message just stays the default (.email({ error: 'x' })"Invalid email"). { message } works on both. Might be worth a one-liner noting the v3/v4 difference and suggesting { message } for anyone supporting both versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a section to the docs to specify this


> The `{ error }` shape is Zod v4-only — on v3 it is silently ignored and the default message is used. If your project supports both Zod v3 and v4, return `{ message: 'My message' }` instead, which works on both.

### dateTimeOptions / timeOptions

**Type:** `Object`
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,18 @@ export interface ZodOptions {
body?: Mutator;
response?: Mutator;
};
/**
* Mutator referencing a function called once per emitted validator at schema
* construction time. It receives codegen-time context (operation, location,
* schema name, field path, validator name) and returns a Zod `params` object
* (e.g. `{ error: ... }`) that is appended as the trailing argument.
*
* The plural name follows Zod's own term for the validator's second argument
* (`z.string(params)`) and is unrelated to the singular `param` key used by
* `generate` / `coerce` / `preprocess` above, which refers to the path-parameter
* location.
*/
params?: Mutator;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny naming flag (defer to you): generate / coerce / preprocess all key the path-parameter location as param (singular), and this context's location can be 'param' too. The new params (plural) next to them leans on singular-vs-plural to separate two unrelated concepts. Keeping params is defensible since it's Zod's term for the validator's 2nd argument — just flagging in case validatorParams reads cleaner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified in the docs as I would like to keep the zod semantics here

dateTimeOptions?: ZodDateTimeOptions;
timeOptions?: ZodTimeOptions;
generateEachHttpStatus?: boolean;
Expand Down Expand Up @@ -802,6 +814,7 @@ export interface NormalizedZodOptions {
body?: NormalizedMutator;
response?: NormalizedMutator;
};
params?: NormalizedMutator;
generateEachHttpStatus: boolean;
useBrandedTypes: boolean;
generateReusableSchemas: boolean;
Expand Down
230 changes: 230 additions & 0 deletions packages/orval/src/generate-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,236 @@ describe('generateSpec - generateReusableSchemas inline (single mode)', () => {
});
});

describe('generateSpec - generateReusableSchemas inline + override.zod.params', () => {
// Regression: with `client: 'zod'` + `generateReusableSchemas: true` and no
// `output.schemas` dir, the inline-reusable writer emits component schemas
// into the operation file (single mode) or an adjacent `.schemas` file
// (split/tags). `override.zod.params` must be threaded through that path so
// the named `export const Pet = …` definitions get `zodParams(…)` injection
// — previously only the operation wrappers in `generateZodRoute` did,
// leaving the shared definitions uninjected.
it('injects zodParams on inline component schemas in single mode', async () => {
const workspace = await createTempWorkspace();
const targetFile = path.join(workspace, 'zod.ts');
const mutatorFile = path.join(workspace, 'zod-params.ts');

try {
await fs.writeFile(
mutatorFile,
'export const zodParams = (_ctx: unknown) => ({});\n',
);

const options = await normalizeOptions(
{
input: { target: PETSTORE_SPEC },
output: {
target: './zod.ts',
mode: 'single',
client: 'zod',
override: {
zod: {
generateReusableSchemas: true,
params: { path: './zod-params.ts', name: 'zodParams' },
},
},
},
},
workspace,
);

await generateSpec(workspace, options);

const content = await fs.readFile(targetFile, 'utf8');

// Inline component schema is injected with location: 'schema' and the
// component's name (not an operation's) — that's the whole point of
// wiring this through the reusable path.
expect(content).toContain('export const Pet = zod.object(');
expect(content).toMatch(
/zodParams\(\{"operationId":"","location":"schema","schemaName":"Pet","fieldPath":\["name"\],"validator":"string"\}\)/,
);
// Exactly one zodParams import in single mode: the operation file
// builder already emits one via each verb's mutators array, and the
// inline schemas live in the same file — emitting a second `import
// { zodParams }` line would be a duplicate.
expect(
content.match(/import \{ zodParams \} from ['"]\.\/zod-params['"]/g) ??
[],
).toHaveLength(1);
} finally {
await rm(workspace, { recursive: true, force: true });
}
});

// Split mode writes the inline schemas to a separate `<name>.schemas.ts`
// file with no other imports, so the params-mutator import has to be
// emitted from the inline writer itself — the outer operation file builder
// can't reach across files.
it('emits the zodParams import in the split-mode schemas file', async () => {
const workspace = await createTempWorkspace();
const schemasFile = path.join(workspace, 'zod.schemas.ts');
const targetFile = path.join(workspace, 'zod.ts');
const mutatorFile = path.join(workspace, 'zod-params.ts');

try {
await fs.writeFile(
mutatorFile,
'export const zodParams = (_ctx: unknown) => ({});\n',
);

const options = await normalizeOptions(
{
input: { target: PETSTORE_SPEC },
output: {
target: './zod.ts',
mode: 'split',
client: 'zod',
override: {
zod: {
generateReusableSchemas: true,
params: { path: './zod-params.ts', name: 'zodParams' },
},
},
},
},
workspace,
);

await generateSpec(workspace, options);

const schemasContent = await fs.readFile(schemasFile, 'utf8');
const targetContent = await fs.readFile(targetFile, 'utf8');

// Injection lands in the schemas file...
expect(schemasContent).toContain('export const Pet = zod.object(');
expect(schemasContent).toMatch(
/zodParams\(\{"operationId":"","location":"schema","schemaName":"Pet","fieldPath":\["name"\],"validator":"string"\}\)/,
);
// ...with its own import (the operation file's import doesn't reach
// here — different file).
expect(schemasContent).toMatch(
/import \{ zodParams \} from ['"]\.\/zod-params['"]/,
);
// The operation file still imports zodParams independently (used by
// operation wrappers via `generateZodRoute`'s mutators array).
expect(targetContent).toMatch(
/import \{ zodParams \} from ['"]\.\/zod-params['"]/,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
});

// `tags` mode looks like `single` but isn't: per-tag operation files live
// alongside a separate `<name>.schemas.ts` file. The schemas file has no
// other imports either, so — same as `split` — it has to emit the params
// import from inside the inline writer. Pinning this so the case can't
// silently regress to "no import → `zodParams` undefined at runtime".
const TAGGED_SPEC: OpenApiDocument = {
...PETSTORE_SPEC,
paths: {
'/pets': {
get: {
...PETSTORE_SPEC.paths?.['/pets']?.get,
tags: ['pets'],
},
},
},
};

it('emits the zodParams import in the tags-mode schemas file', async () => {
const workspace = await createTempWorkspace();
const schemasFile = path.join(workspace, 'zod.schemas.ts');
const mutatorFile = path.join(workspace, 'zod-params.ts');

try {
await fs.writeFile(
mutatorFile,
'export const zodParams = (_ctx: unknown) => ({});\n',
);

const options = await normalizeOptions(
{
input: { target: TAGGED_SPEC },
output: {
target: './zod.ts',
mode: 'tags',
client: 'zod',
override: {
zod: {
generateReusableSchemas: true,
params: { path: './zod-params.ts', name: 'zodParams' },
},
},
},
},
workspace,
);

await generateSpec(workspace, options);

const schemasContent = await fs.readFile(schemasFile, 'utf8');

expect(schemasContent).toContain('export const Pet = zod.object(');
expect(schemasContent).toMatch(
/zodParams\(\{"operationId":"","location":"schema","schemaName":"Pet","fieldPath":\["name"\],"validator":"string"\}\)/,
);
expect(schemasContent).toMatch(
/import \{ zodParams \} from ['"]\.\/zod-params['"]/,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
});

it('emits the zodParams import in the tags-split schemas file', async () => {
const workspace = await createTempWorkspace();
// In tags-split, operation files nest under `<dirname>/<tag>/<tag>.ts`
// but the inline schemas file stays at the root next to `zod-params.ts`.
const schemasFile = path.join(workspace, 'zod.schemas.ts');
const mutatorFile = path.join(workspace, 'zod-params.ts');

try {
await fs.writeFile(
mutatorFile,
'export const zodParams = (_ctx: unknown) => ({});\n',
);

const options = await normalizeOptions(
{
input: { target: TAGGED_SPEC },
output: {
target: './zod.ts',
mode: 'tags-split',
client: 'zod',
override: {
zod: {
generateReusableSchemas: true,
params: { path: './zod-params.ts', name: 'zodParams' },
},
},
},
},
workspace,
);

await generateSpec(workspace, options);

const schemasContent = await fs.readFile(schemasFile, 'utf8');

expect(schemasContent).toContain('export const Pet = zod.object(');
expect(schemasContent).toMatch(
/zodParams\(\{"operationId":"","location":"schema","schemaName":"Pet","fieldPath":\["name"\],"validator":"string"\}\)/,
);
expect(schemasContent).toMatch(
/import \{ zodParams \} from ['"]\.\/zod-params['"]/,
);
} finally {
await rm(workspace, { recursive: true, force: true });
}
});
});

describe('generateSpec - generateReusableSchemas recursive ($ref to self)', () => {
// Regression: a self-referential component schema is emitted as a single
// reusable `const` whose initializer references itself through `zod.lazy`.
Expand Down
1 change: 1 addition & 0 deletions packages/orval/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { generate as default, generate } from './generate';
export { defineConfig, defineTransformer } from './utils/options';
export type { Options } from '@orval/core';
export * from '@orval/core';
export type { ZodParamsContext } from '@orval/zod';
Loading
Loading