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
74 changes: 68 additions & 6 deletions apps/content/docs/openapi/openapi-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,14 @@ export class ValibotToJsonSchemaConverter implements ConditionalSchemaConverter
It's recommended to use the built-in converters because the oRPC implementations handle many edge cases and supports every type that oRPC offers.
:::

```ts twoslash
import { contract, router } from './shared/planet'
// ---cut---
```ts
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import {
ZodToJsonSchemaConverter
} from '@orpc/zod' // <-- zod v3
import {
experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter
} from '@orpc/zod/zod4' // <-- zod v4
import {
experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter
} from '@orpc/valibot'
Expand Down Expand Up @@ -163,7 +166,66 @@ The `.spec` helper accepts a callback as its second argument, allowing you to ov

## `@orpc/zod`

### File Schema
### Zod v4

#### File Schema

Zod v4 includes a native `File` schema. oRPC will detect it automatically - no extra setup needed:

```ts
import * as z from 'zod'

const InputSchema = z.object({
file: oz.file(),
image: oz.file().mine(['image/png', 'image/jpeg']),
})
```

#### JSON Schema Customization

`description` and `examples` metadata are supported out of the box:

```ts
import * as z from 'zod'

const InputSchema = z.object({
name: z.string(),
}).meta({
description: 'User schema',
examples: [{ name: 'John' }],
})
```

For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_INPUT_REGISTRY`, and `JSON_SCHEMA_OUTPUT_REGISTRY`:

```ts
import * as z from 'zod'
import {
experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY,
} from '@orpc/zod/zod4'

export const InputSchema = z.object({
name: z.string(),
})

JSON_SCHEMA_REGISTRY.add(InputSchema, {
description: 'User schema',
examples: [{ name: 'John' }],
// other options...
})

JSON_SCHEMA_INPUT_REGISTRY.add(InputSchema, {
// only for .input
})

JSON_SCHEMA_OUTPUT_REGISTRY.add(InputSchema, {
// only for .output
})
```

### Zod v3

#### File Schema

In the [File Upload/Download](/docs/file-upload-download) guide, `z.instanceof` is used to describe file/blob schemas. However, this method prevents oRPC from recognizing file/blob schema. Instead, use the enhanced file schema approach:

Expand All @@ -178,7 +240,7 @@ const InputSchema = z.object({
})
```

### JSON Schema Customization
#### JSON Schema Customization

If Zod alone does not cover your JSON Schema requirements, you can extend or override the generated schema:

Expand Down
9 changes: 8 additions & 1 deletion apps/content/docs/openapi/plugins/zod-smart-coercion.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ description: A refined alternative to `z.coerce` that automatically converts inp

A Plugin refined alternative to `z.coerce` that automatically converts inputs to the expected type without modifying the input schema.

::: warning
In Zod v4, this plugin only supports **discriminated unions**. Regular (non-discriminated) unions are **not** coerced automatically.
:::

## Installation

::: code-group
Expand Down Expand Up @@ -37,7 +41,10 @@ deno install npm:@orpc/zod@latest

```ts
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { ZodSmartCoercionPlugin } from '@orpc/zod'
import { ZodSmartCoercionPlugin } from '@orpc/zod' // <-- zod v3
import {
experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin
} from '@orpc/zod/zod4' // <-- zod v4

const handler = new OpenAPIHandler(router, {
plugins: [new ZodSmartCoercionPlugin()]
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable no-restricted-imports */
import type { JSONSchema, keywords } from 'json-schema-typed/draft-2020-12'
import { Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12'
import { ContentEncoding as JSONSchemaContentEncoding, Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12'

export { JSONSchemaFormat }
export { JSONSchemaContentEncoding, JSONSchemaFormat }
export type { JSONSchema }

/**
Expand Down
25 changes: 22 additions & 3 deletions packages/zod/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./zod4": {
"types": "./dist/zod4/index.d.mts",
"import": "./dist/zod4/index.mjs",
"default": "./dist/zod4/index.mjs"
}
}
},
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./zod4": "./src/zod4/index.ts"
},
"files": [
"dist"
Expand All @@ -36,7 +42,16 @@
"peerDependencies": {
"@orpc/contract": "workspace:*",
"@orpc/server": "workspace:*",
"zod": "^3.24.2"
"@zod/core": ">=0.11.4",
"zod": ">=3.24.2"
},
"peerDependenciesMeta": {
"@zod/core": {
"optional": true
},
"zod": {
"optional": true
}
},
"dependencies": {
"@orpc/openapi": "workspace:*",
Expand All @@ -45,6 +60,10 @@
"wildcard-match": "^5.1.3"
},
"devDependencies": {
"zod-to-json-schema": "^3.24.5"
"@zod/core": "^0.11.4",
"@zod/mini": "^4.0.0-beta.20250505T012514",
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5",
"zod4": "npm:zod@^4.0.0-beta.20250505T012514"
}
}
151 changes: 151 additions & 0 deletions packages/zod/src/zod4/coercer.combination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import z from 'zod4'
import { testSchemaSmartCoercion } from '../../tests/shared'

const InfiniteLazySchema = z.lazy(() => z.object({ boolean: z.boolean(), value: z.lazy(() => InfiniteLazySchema) })) as any

testSchemaSmartCoercion([
{
name: 'union - 123 - un-discriminated',
schema: z.union([z.boolean(), z.number()]),
input: '123',
},
{
name: 'union - object boolean - un-discriminated',
schema: z.union([z.object({ a: z.boolean() }), z.object({ b: z.number() })]),
input: { a: 'true' },
},
{
name: 'union - only one option',
schema: z.union([z.boolean()]),
input: 'true',
expected: true,
},
{
name: 'union - one discriminated',
schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ b: z.number() })]),
input: { a: 'type1', b: '123' },
expected: { a: 'type1', b: 123 },
},
{
name: 'union - discriminated',
schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
input: { a: 'type2', b: '123' },
expected: { a: 'type2', b: 123n },
},
{
name: 'union - complex discriminated 1',
schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
input: { a: { v: 'type1' }, b: '123' },
expected: { a: { v: 'type1' }, b: 123 },
},
{
name: 'union - complex discriminated 2',
schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
input: { a: 'type1', b: '123' },
},
{
name: 'union - complex discriminated 3',
schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
input: { a: { v: 'type2' }, b: '123' },
},
{
name: 'union - not coerce discriminated key',
schema: z.union([z.object({ a: z.literal(true), b: z.number() }), z.object({ a: z.literal(false), b: z.bigint() })]),
input: { a: 'true', b: '123' },
},
{
name: 'intersection - 123',
schema: z.object({ a: z.number() }).and(z.object({ b: z.boolean() })),
input: { a: '1234', b: 'true' },
expected: { a: 1234, b: true },
},
{
name: 'boolean - readonly',
schema: z.boolean().readonly(),
input: 'true',
expected: true,
},
{
name: 'pipe - boolean',
schema: z.boolean().pipe(z.transform(() => '1')).pipe(z.string()),
input: 'true',
expected: true,
},
{
name: 'transform - boolean',
schema: z.boolean().transform(() => {}),
input: 'true',
expected: true,
},
{
name: 'brand - boolean',
schema: z.boolean().brand<'CAT'>(),
input: 'true',
expected: true,
},
{
name: 'catch - boolean',
schema: z.boolean().catch(false),
input: 'true',
expected: true,
},
{
name: 'default - boolean',
schema: z.boolean().default(false),
input: 'true',
expected: true,
},
{
name: 'nullable - boolean',
schema: z.boolean().nullable(),
input: 'true',
expected: true,
},
{
name: 'nullable - null',
schema: z.boolean().nullable(),
input: null,
expected: null,
},
{
name: 'optional - boolean',
schema: z.boolean().optional(),
input: 'true',
expected: true,
},
{
name: 'optional - undefined',
schema: z.boolean().optional(),
input: undefined,
expected: undefined,
},
{
name: 'optional - non optional - undefined',
schema: z.boolean().optional().nonoptional(),
input: undefined,
expected: undefined,
},
{
name: 'optional - non optional - true',
schema: z.boolean().optional().nonoptional(),
input: 'on',
expected: true,
},
{
name: 'lazy - true',
schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })),
input: { value: { value: 'true' } },
expected: { value: { value: true } },
},
{
name: 'lazy - invalid',
schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })),
input: { value: { value: 'invalid' } },
},
{
name: 'lazy - InfiniteLazySchema',
schema: InfiniteLazySchema,
input: { value: { boolean: 'true' } },
expected: { value: { boolean: true } },
},
])
Loading