Skip to content

Commit 3826b44

Browse files
authored
feat(zod): support JSON schema registries (#1695)
Brings the v1 `JSON_SCHEMA_REGISTRY`, `JSON_SCHEMA_INPUT_REGISTRY`, and `JSON_SCHEMA_OUTPUT_REGISTRY` to `@orpc/zod` v2, so users can customize the generated JSON schema per Zod schema. ```ts import { JSON_SCHEMA_REGISTRY } from '@orpc/zod' const user = z.object({ name: z.string(), age: z.number(), }) JSON_SCHEMA_REGISTRY.add(user, { examples: [{ name: 'John', age: 20 }], }) ``` - Registry entries are merged over the generated schema at any nesting level; direction-specific registries take precedence over the general one, matching v1 behavior. - Registries are typed with `$input`/`$output`, so `examples`, `default`, etc. are checked against the schema's input/output types.
1 parent 7106a52 commit 3826b44

8 files changed

Lines changed: 192 additions & 2 deletions

File tree

packages/json-schema/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// eslint-disable-next-line no-restricted-imports
22
import type * as Draft2020 from 'json-schema-typed/draft-2020-12'
33

4-
export type JsonSchema = Draft2020.JSONSchema
4+
export type JsonSchema<Value = any> = Draft2020.JSONSchema<Value>
55
export type JsonSchemaKeywords = typeof Draft2020.keywords[number]
66

77
export enum JsonSchemaXNativeType {

packages/zod/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
"zod": ">=4.3.5"
4040
},
4141
"dependencies": {
42-
"@orpc/json-schema": "workspace:*"
42+
"@orpc/json-schema": "workspace:*",
43+
"json-schema-typed": "^8.0.2"
4344
},
4445
"devDependencies": {
4546
"zod": "^4.4.3"

packages/zod/src/converter.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as v from 'valibot'
22
import * as z from 'zod'
33
import { $ZodRegistry, toJSONSchema } from 'zod/v4/core'
44
import { ZodToJsonSchemaConverter } from './converter'
5+
import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries'
56

67
vi.mock('zod/v4/core', async (original) => {
78
const mod = await original<typeof import('zod/v4/core')>()
@@ -227,4 +228,62 @@ describe('zodToJsonSchemaConverter', () => {
227228
expect(converter.convert(schema, 'input')).toEqual([jsonSchema, false])
228229
})
229230
})
231+
232+
describe('custom json schema registries', () => {
233+
it('merges JSON_SCHEMA_REGISTRY entries over the generated schema for both directions', () => {
234+
const schema = z.string().min(3)
235+
JSON_SCHEMA_REGISTRY.add(schema, { examples: ['example'], minLength: 5 })
236+
237+
expect(converter.convert(schema, 'input')).toEqual([{
238+
type: 'string',
239+
minLength: 5,
240+
examples: ['example'],
241+
}, false])
242+
243+
expect(converter.convert(schema, 'output')).toEqual([{
244+
type: 'string',
245+
minLength: 5,
246+
examples: ['example'],
247+
}, false])
248+
})
249+
250+
it('prefers direction-specific registries over JSON_SCHEMA_REGISTRY', () => {
251+
const schema = z.codec(z.string(), z.number(), {
252+
decode: value => Number(value),
253+
encode: value => String(value),
254+
})
255+
256+
JSON_SCHEMA_REGISTRY.add(schema, { description: 'general' })
257+
JSON_SCHEMA_INPUT_REGISTRY.add(schema, { examples: ['20'] })
258+
JSON_SCHEMA_OUTPUT_REGISTRY.add(schema, { examples: [20] })
259+
260+
expect(converter.convert(schema, 'input')).toEqual([{
261+
type: 'string',
262+
examples: ['20'],
263+
}, false])
264+
265+
expect(converter.convert(schema, 'output')).toEqual([{
266+
type: 'number',
267+
examples: [20],
268+
}, false])
269+
})
270+
271+
it('applies to nested schemas', () => {
272+
const name = z.string()
273+
JSON_SCHEMA_REGISTRY.add(name, { description: 'name field' })
274+
275+
const schema = z.object({ name })
276+
277+
expect(converter.convert(schema, 'input')).toEqual([{
278+
type: 'object',
279+
properties: {
280+
name: {
281+
type: 'string',
282+
description: 'name field',
283+
},
284+
},
285+
required: ['name'],
286+
}, false])
287+
})
288+
})
230289
})

packages/zod/src/converter.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AnySchema, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDir
22
import type { $ZodType, ToJSONSchemaParams, JSONSchema as ZodJsonSchema } from 'zod/v4/core'
33
import { encodeJsonPointerSegment, JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema'
44
import { globalRegistry, toJSONSchema } from 'zod/v4/core'
5+
import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries'
56

67
export interface ZodToJsonSchemaConverterOptions extends Omit<ToJSONSchemaParams, 'target' | 'io'> {}
78

@@ -68,6 +69,12 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter {
6869
ctx.jsonSchema['x-native-type'] = JsonSchemaXNativeType.Map
6970
}
7071

72+
const customJsonSchema = this.getCustomJsonSchema(ctx.zodSchema, direction)
73+
74+
if (customJsonSchema) {
75+
Object.assign(ctx.jsonSchema, customJsonSchema)
76+
}
77+
7178
this.options.override?.(ctx)
7279
},
7380
})
@@ -99,4 +106,18 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter {
99106

100107
return rest
101108
}
109+
110+
private getCustomJsonSchema(schema: $ZodType, direction: JsonSchemaConverterDirection): Exclude<JsonSchema, boolean> | undefined {
111+
if (direction === 'input' && JSON_SCHEMA_INPUT_REGISTRY.has(schema)) {
112+
return JSON_SCHEMA_INPUT_REGISTRY.get(schema) as Exclude<JsonSchema, boolean> | undefined
113+
}
114+
115+
if (direction === 'output' && JSON_SCHEMA_OUTPUT_REGISTRY.has(schema)) {
116+
return JSON_SCHEMA_OUTPUT_REGISTRY.get(schema) as Exclude<JsonSchema, boolean> | undefined
117+
}
118+
119+
if (JSON_SCHEMA_REGISTRY.has(schema)) {
120+
return JSON_SCHEMA_REGISTRY.get(schema) as Exclude<JsonSchema, boolean> | undefined
121+
}
122+
}
102123
}

packages/zod/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './converter'
2+
export * from './registries'
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as z from 'zod'
2+
import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY } from './registries'
3+
4+
const user = z.object({
5+
name: z.string(),
6+
age: z.string().transform(v => Number(v)),
7+
})
8+
9+
describe('JSON_SCHEMA_REGISTRY', () => {
10+
it('accepts both input and output shapes', () => {
11+
JSON_SCHEMA_REGISTRY.add(user, { examples: [{ name: 'John', age: '20' }] })
12+
JSON_SCHEMA_REGISTRY.add(user, { examples: [{ name: 'John', age: 20 }] })
13+
JSON_SCHEMA_REGISTRY.add(user, { default: { name: 'John', age: '20' } })
14+
JSON_SCHEMA_REGISTRY.add(user, { default: { name: 'John', age: 20 } })
15+
16+
// @ts-expect-error --- age must match input or output type
17+
JSON_SCHEMA_REGISTRY.add(user, { examples: [{ name: 'John', age: true }] })
18+
// @ts-expect-error --- age is required
19+
JSON_SCHEMA_REGISTRY.add(user, { default: { name: 'John' } })
20+
})
21+
})
22+
23+
describe('JSON_SCHEMA_INPUT_REGISTRY', () => {
24+
it('only accepts input shapes', () => {
25+
JSON_SCHEMA_INPUT_REGISTRY.add(user, { examples: [{ name: 'John', age: '20' }] })
26+
JSON_SCHEMA_INPUT_REGISTRY.add(user, { default: { name: 'John', age: '20' } })
27+
28+
// @ts-expect-error --- age must be the input type (string)
29+
JSON_SCHEMA_INPUT_REGISTRY.add(user, { examples: [{ name: 'John', age: 20 }] })
30+
// @ts-expect-error --- age must be the input type (string)
31+
JSON_SCHEMA_INPUT_REGISTRY.add(user, { default: { name: 'John', age: 20 } })
32+
})
33+
})
34+
35+
describe('JSON_SCHEMA_OUTPUT_REGISTRY', () => {
36+
it('only accepts output shapes', () => {
37+
JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { examples: [{ name: 'John', age: 20 }] })
38+
JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { default: { name: 'John', age: 20 } })
39+
40+
// @ts-expect-error --- age must be the output type (number)
41+
JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { examples: [{ name: 'John', age: '20' }] })
42+
// @ts-expect-error --- age must be the output type (number)
43+
JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { default: { name: 'John', age: '20' } })
44+
})
45+
})

packages/zod/src/registries.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { JsonSchema } from '@orpc/json-schema'
2+
import type { $input, $output } from 'zod/v4/core'
3+
import { registry } from 'zod/v4/core'
4+
5+
/**
6+
* Zod registry for customizing generated JSON schema, can use both for .input and .output
7+
*
8+
* @example
9+
* ```ts
10+
* import { JSON_SCHEMA_REGISTRY } from '@orpc/zod'
11+
*
12+
* const user = z.object({
13+
* name: z.string(),
14+
* age: z.number(),
15+
* })
16+
*
17+
* JSON_SCHEMA_REGISTRY.add(user, {
18+
* examples: [{ name: 'John', age: 20 }],
19+
* })
20+
* ```
21+
*/
22+
export const JSON_SCHEMA_REGISTRY = registry<Exclude<JsonSchema<$input | $output>, boolean>>()
23+
24+
/**
25+
* Zod registry for customizing generated JSON schema, only useful for .input
26+
*
27+
* @example
28+
* ```ts
29+
* import { JSON_SCHEMA_INPUT_REGISTRY } from '@orpc/zod'
30+
*
31+
* const user = z.object({
32+
* name: z.string(),
33+
* age: z.string().transform(v => Number(v)),
34+
* })
35+
*
36+
* JSON_SCHEMA_INPUT_REGISTRY.add(user, {
37+
* examples: [{ name: 'John', age: "20" }],
38+
* })
39+
* ```
40+
*/
41+
export const JSON_SCHEMA_INPUT_REGISTRY = registry<Exclude<JsonSchema<$input>, boolean>>()
42+
43+
/**
44+
* Zod registry for customizing generated JSON schema, only useful for .output
45+
*
46+
* @example
47+
* ```ts
48+
* import { JSON_SCHEMA_OUTPUT_REGISTRY } from '@orpc/zod'
49+
*
50+
* const user = z.object({
51+
* name: z.string(),
52+
* age: z.string().transform(v => Number(v)),
53+
* })
54+
*
55+
* JSON_SCHEMA_OUTPUT_REGISTRY.add(user, {
56+
* examples: [{ name: 'John', age: 20 }],
57+
* })
58+
* ```
59+
*/
60+
export const JSON_SCHEMA_OUTPUT_REGISTRY = registry<Exclude<JsonSchema<$output>, boolean>>()

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)