Skip to content

Commit d5f6b77

Browse files
authored
feat(zod): experimental support zod v4 (#462)
Closes: #428 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added experimental support for Zod v4, including a new JSON Schema converter and smart coercion plugin. - Introduced advanced JSON Schema customization registries for Zod v4. - Dual support for Zod v3 and v4 now available with explicit exports and dependency declarations. - **Documentation** - Enhanced OpenAPI and plugin documentation to clearly distinguish Zod v3 and v4 usage, features, and import instructions. - **Tests** - Added comprehensive test suites for Zod v4 covering schema conversion, smart coercion, and registry integration. - **Chores** - Updated package configuration to support Zod v4 and its dependencies. - Added new exports and peer dependencies for Zod v4 support. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9878430 commit d5f6b77

24 files changed

+2396
-13
lines changed

apps/content/docs/openapi/openapi-specification.md

+68-6
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,14 @@ export class ValibotToJsonSchemaConverter implements ConditionalSchemaConverter
6666
It's recommended to use the built-in converters because the oRPC implementations handle many edge cases and supports every type that oRPC offers.
6767
:::
6868

69-
```ts twoslash
70-
import { contract, router } from './shared/planet'
71-
// ---cut---
69+
```ts
7270
import { OpenAPIGenerator } from '@orpc/openapi'
73-
import { ZodToJsonSchemaConverter } from '@orpc/zod'
71+
import {
72+
ZodToJsonSchemaConverter
73+
} from '@orpc/zod' // <-- zod v3
74+
import {
75+
experimental_ZodToJsonSchemaConverter as ZodToJsonSchemaConverter
76+
} from '@orpc/zod/zod4' // <-- zod v4
7477
import {
7578
experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter
7679
} from '@orpc/valibot'
@@ -163,7 +166,66 @@ The `.spec` helper accepts a callback as its second argument, allowing you to ov
163166

164167
## `@orpc/zod`
165168

166-
### File Schema
169+
### Zod v4
170+
171+
#### File Schema
172+
173+
Zod v4 includes a native `File` schema. oRPC will detect it automatically - no extra setup needed:
174+
175+
```ts
176+
import * as z from 'zod'
177+
178+
const InputSchema = z.object({
179+
file: oz.file(),
180+
image: oz.file().mine(['image/png', 'image/jpeg']),
181+
})
182+
```
183+
184+
#### JSON Schema Customization
185+
186+
`description` and `examples` metadata are supported out of the box:
187+
188+
```ts
189+
import * as z from 'zod'
190+
191+
const InputSchema = z.object({
192+
name: z.string(),
193+
}).meta({
194+
description: 'User schema',
195+
examples: [{ name: 'John' }],
196+
})
197+
```
198+
199+
For further customization, you can use the `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_INPUT_REGISTRY`, and `JSON_SCHEMA_OUTPUT_REGISTRY`:
200+
201+
```ts
202+
import * as z from 'zod'
203+
import {
204+
experimental_JSON_SCHEMA_REGISTRY as JSON_SCHEMA_REGISTRY,
205+
} from '@orpc/zod/zod4'
206+
207+
export const InputSchema = z.object({
208+
name: z.string(),
209+
})
210+
211+
JSON_SCHEMA_REGISTRY.add(InputSchema, {
212+
description: 'User schema',
213+
examples: [{ name: 'John' }],
214+
// other options...
215+
})
216+
217+
JSON_SCHEMA_INPUT_REGISTRY.add(InputSchema, {
218+
// only for .input
219+
})
220+
221+
JSON_SCHEMA_OUTPUT_REGISTRY.add(InputSchema, {
222+
// only for .output
223+
})
224+
```
225+
226+
### Zod v3
227+
228+
#### File Schema
167229

168230
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:
169231

@@ -178,7 +240,7 @@ const InputSchema = z.object({
178240
})
179241
```
180242

181-
### JSON Schema Customization
243+
#### JSON Schema Customization
182244

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

apps/content/docs/openapi/plugins/zod-smart-coercion.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ description: A refined alternative to `z.coerce` that automatically converts inp
77

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

10+
::: warning
11+
In Zod v4, this plugin only supports **discriminated unions**. Regular (non-discriminated) unions are **not** coerced automatically.
12+
:::
13+
1014
## Installation
1115

1216
::: code-group
@@ -37,7 +41,10 @@ deno install npm:@orpc/zod@latest
3741

3842
```ts
3943
import { OpenAPIHandler } from '@orpc/openapi/fetch'
40-
import { ZodSmartCoercionPlugin } from '@orpc/zod'
44+
import { ZodSmartCoercionPlugin } from '@orpc/zod' // <-- zod v3
45+
import {
46+
experimental_ZodSmartCoercionPlugin as ZodSmartCoercionPlugin
47+
} from '@orpc/zod/zod4' // <-- zod v4
4148
4249
const handler = new OpenAPIHandler(router, {
4350
plugins: [new ZodSmartCoercionPlugin()]

packages/openapi/src/schema.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/* eslint-disable no-restricted-imports */
22
import type { JSONSchema, keywords } from 'json-schema-typed/draft-2020-12'
3-
import { Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12'
3+
import { ContentEncoding as JSONSchemaContentEncoding, Format as JSONSchemaFormat } from 'json-schema-typed/draft-2020-12'
44

5-
export { JSONSchemaFormat }
5+
export { JSONSchemaContentEncoding, JSONSchemaFormat }
66
export type { JSONSchema }
77

88
/**

packages/zod/package.json

+22-3
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,17 @@
1919
"types": "./dist/index.d.mts",
2020
"import": "./dist/index.mjs",
2121
"default": "./dist/index.mjs"
22+
},
23+
"./zod4": {
24+
"types": "./dist/zod4/index.d.mts",
25+
"import": "./dist/zod4/index.mjs",
26+
"default": "./dist/zod4/index.mjs"
2227
}
2328
}
2429
},
2530
"exports": {
26-
".": "./src/index.ts"
31+
".": "./src/index.ts",
32+
"./zod4": "./src/zod4/index.ts"
2733
},
2834
"files": [
2935
"dist"
@@ -36,7 +42,16 @@
3642
"peerDependencies": {
3743
"@orpc/contract": "workspace:*",
3844
"@orpc/server": "workspace:*",
39-
"zod": "^3.24.2"
45+
"@zod/core": ">=0.11.4",
46+
"zod": ">=3.24.2"
47+
},
48+
"peerDependenciesMeta": {
49+
"@zod/core": {
50+
"optional": true
51+
},
52+
"zod": {
53+
"optional": true
54+
}
4055
},
4156
"dependencies": {
4257
"@orpc/openapi": "workspace:*",
@@ -45,6 +60,10 @@
4560
"wildcard-match": "^5.1.3"
4661
},
4762
"devDependencies": {
48-
"zod-to-json-schema": "^3.24.5"
63+
"@zod/core": "^0.11.4",
64+
"@zod/mini": "^4.0.0-beta.20250505T012514",
65+
"zod": "^3.24.2",
66+
"zod-to-json-schema": "^3.24.5",
67+
"zod4": "npm:zod@^4.0.0-beta.20250505T012514"
4968
}
5069
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import z from 'zod4'
2+
import { testSchemaSmartCoercion } from '../../tests/shared'
3+
4+
const InfiniteLazySchema = z.lazy(() => z.object({ boolean: z.boolean(), value: z.lazy(() => InfiniteLazySchema) })) as any
5+
6+
testSchemaSmartCoercion([
7+
{
8+
name: 'union - 123 - un-discriminated',
9+
schema: z.union([z.boolean(), z.number()]),
10+
input: '123',
11+
},
12+
{
13+
name: 'union - object boolean - un-discriminated',
14+
schema: z.union([z.object({ a: z.boolean() }), z.object({ b: z.number() })]),
15+
input: { a: 'true' },
16+
},
17+
{
18+
name: 'union - only one option',
19+
schema: z.union([z.boolean()]),
20+
input: 'true',
21+
expected: true,
22+
},
23+
{
24+
name: 'union - one discriminated',
25+
schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ b: z.number() })]),
26+
input: { a: 'type1', b: '123' },
27+
expected: { a: 'type1', b: 123 },
28+
},
29+
{
30+
name: 'union - discriminated',
31+
schema: z.union([z.object({ a: z.literal('type1'), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
32+
input: { a: 'type2', b: '123' },
33+
expected: { a: 'type2', b: 123n },
34+
},
35+
{
36+
name: 'union - complex discriminated 1',
37+
schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
38+
input: { a: { v: 'type1' }, b: '123' },
39+
expected: { a: { v: 'type1' }, b: 123 },
40+
},
41+
{
42+
name: 'union - complex discriminated 2',
43+
schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
44+
input: { a: 'type1', b: '123' },
45+
},
46+
{
47+
name: 'union - complex discriminated 3',
48+
schema: z.union([z.object({ a: z.object({ v: z.literal('type1') }), b: z.number() }), z.object({ a: z.literal('type2'), b: z.bigint() })]),
49+
input: { a: { v: 'type2' }, b: '123' },
50+
},
51+
{
52+
name: 'union - not coerce discriminated key',
53+
schema: z.union([z.object({ a: z.literal(true), b: z.number() }), z.object({ a: z.literal(false), b: z.bigint() })]),
54+
input: { a: 'true', b: '123' },
55+
},
56+
{
57+
name: 'intersection - 123',
58+
schema: z.object({ a: z.number() }).and(z.object({ b: z.boolean() })),
59+
input: { a: '1234', b: 'true' },
60+
expected: { a: 1234, b: true },
61+
},
62+
{
63+
name: 'boolean - readonly',
64+
schema: z.boolean().readonly(),
65+
input: 'true',
66+
expected: true,
67+
},
68+
{
69+
name: 'pipe - boolean',
70+
schema: z.boolean().pipe(z.transform(() => '1')).pipe(z.string()),
71+
input: 'true',
72+
expected: true,
73+
},
74+
{
75+
name: 'transform - boolean',
76+
schema: z.boolean().transform(() => {}),
77+
input: 'true',
78+
expected: true,
79+
},
80+
{
81+
name: 'brand - boolean',
82+
schema: z.boolean().brand<'CAT'>(),
83+
input: 'true',
84+
expected: true,
85+
},
86+
{
87+
name: 'catch - boolean',
88+
schema: z.boolean().catch(false),
89+
input: 'true',
90+
expected: true,
91+
},
92+
{
93+
name: 'default - boolean',
94+
schema: z.boolean().default(false),
95+
input: 'true',
96+
expected: true,
97+
},
98+
{
99+
name: 'nullable - boolean',
100+
schema: z.boolean().nullable(),
101+
input: 'true',
102+
expected: true,
103+
},
104+
{
105+
name: 'nullable - null',
106+
schema: z.boolean().nullable(),
107+
input: null,
108+
expected: null,
109+
},
110+
{
111+
name: 'optional - boolean',
112+
schema: z.boolean().optional(),
113+
input: 'true',
114+
expected: true,
115+
},
116+
{
117+
name: 'optional - undefined',
118+
schema: z.boolean().optional(),
119+
input: undefined,
120+
expected: undefined,
121+
},
122+
{
123+
name: 'optional - non optional - undefined',
124+
schema: z.boolean().optional().nonoptional(),
125+
input: undefined,
126+
expected: undefined,
127+
},
128+
{
129+
name: 'optional - non optional - true',
130+
schema: z.boolean().optional().nonoptional(),
131+
input: 'on',
132+
expected: true,
133+
},
134+
{
135+
name: 'lazy - true',
136+
schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })),
137+
input: { value: { value: 'true' } },
138+
expected: { value: { value: true } },
139+
},
140+
{
141+
name: 'lazy - invalid',
142+
schema: z.lazy(() => z.object({ value: z.lazy(() => z.object({ value: z.boolean() })) })),
143+
input: { value: { value: 'invalid' } },
144+
},
145+
{
146+
name: 'lazy - InfiniteLazySchema',
147+
schema: InfiniteLazySchema,
148+
input: { value: { boolean: 'true' } },
149+
expected: { value: { boolean: true } },
150+
},
151+
])

0 commit comments

Comments
 (0)