Skip to content

Commit 21251bb

Browse files
authored
feat(zod,valibot,arktype): add option to cache generated JSON schemas (#1753)
Adds an opt-in `cache` option to `ZodToJsonSchemaConverter`, `ValibotToJsonSchemaConverter`, and `ArkTypeToJsonSchemaConverter` that memoizes conversion results in per-direction WeakMaps keyed by the schema instance. Converting the same schema repeatedly — e.g. regenerating an OpenAPI spec or sharing a converter across plugins — no longer re-runs the underlying JSON schema conversion. ## Performance - Repeat conversions of the same schema instance return the cached `[jsonSchema, optional]` tuple with no conversion work. - `input` and `output` directions are cached independently, and WeakMap keys keep schemas garbage-collectable. ## Notes - Disabled by default: cached results are shared objects, so they must be treated as immutable when the option is enabled. - The `cache` flag is stripped before options are forwarded to the underlying `toJsonSchema` implementations. ## Testing - New tests in all three packages verify results are reused per schema and direction, the underlying conversion runs only once, and behavior is unchanged when disabled. - Docs for the zod, valibot, and arktype integrations mention the new option.
1 parent df573c1 commit 21251bb

9 files changed

Lines changed: 261 additions & 12 deletions

File tree

apps/content/docs/integrations/arktype.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ const generator = new OpenAPIGenerator({
4141
})
4242
```
4343

44+
::: tip
45+
Enable the `cache` option to reuse conversion results when the same schema instance is converted repeatedly. When enabled, repeated conversions return the same JSON schema object, so treat the results as immutable.
46+
47+
```ts
48+
const converter = new ArkTypeToJsonSchemaConverter({ cache: true })
49+
```
50+
51+
:::
52+
4453
### Reusable Types
4554

4655
A common pattern is defining reusable or recursive types using scopes. The converter preserves them in `$defs`, which `OpenAPIGenerator` can then [hoist](/docs/openapi/specification#hoisting-defs) into `components.schemas`.

apps/content/docs/integrations/valibot.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ const generator = new OpenAPIGenerator({
4141
})
4242
```
4343

44+
::: tip
45+
Enable the `cache` option to reuse conversion results when the same schema instance is converted repeatedly. When enabled, repeated conversions return the same JSON schema object, so treat the results as immutable.
46+
47+
```ts
48+
const converter = new ValibotToJsonSchemaConverter({ cache: true })
49+
```
50+
51+
:::
52+
4453
### Reusable Schemas
4554

4655
A common pattern is defining reusable or recursive schemas via definitions. The converter preserves them in `$defs`, which `OpenAPIGenerator` can then [hoist](/docs/openapi/specification#hoisting-defs) into `components.schemas`. For more on how definitions work in Valibot, see [Valibot JSON Schema Definitions](https://github.com/open-circle/valibot/blob/main/packages/to-json-schema/README.md#definitions).

apps/content/docs/integrations/zod.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ const generator = new OpenAPIGenerator({
4545
})
4646
```
4747

48+
::: tip
49+
Enable the `cache` option to reuse conversion results when the same schema instance is converted repeatedly. When enabled, repeated conversions return the same JSON schema object, so treat the results as immutable.
50+
51+
```ts
52+
const converter = new ZodToJsonSchemaConverter({ cache: true })
53+
```
54+
55+
:::
56+
4857
### Reusable Schemas
4958

5059
A common pattern is defining reusable schemas with `id` metadata. The converter places them in `$defs`, which `OpenAPIGenerator` then [hoists](/docs/openapi/specification#hoisting-defs) into `components.schemas`. For more on `id` and `$ref` in Zod, see [Zod JSON Schema Registries](https://zod.dev/json-schema?id=registries#registries).

packages/arktype/src/converter.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,38 @@ describe('arkTypeToJsonSchemaConverter', () => {
127127
false,
128128
])
129129
})
130+
131+
describe('cache option', () => {
132+
it('reuses conversion results per schema and direction when enabled', () => {
133+
const converter = new ArkTypeToJsonSchemaConverter({ cache: true })
134+
const schema = type('string')
135+
// arktype interns types, so the same definition returns the same instance/spy across tests
136+
const toJsonSchema = vi.spyOn(schema, 'toJsonSchema')
137+
toJsonSchema.mockClear()
138+
139+
const input = converter.convert(schema, 'input')
140+
expect(input).toEqual([{ type: 'string' }, false])
141+
expect(converter.convert(schema, 'input')).toBe(input)
142+
expect(toJsonSchema).toHaveBeenCalledTimes(1)
143+
144+
const output = converter.convert(schema, 'output')
145+
expect(output).toEqual([{ type: 'string' }, false])
146+
expect(output).not.toBe(input)
147+
expect(converter.convert(schema, 'output')).toBe(output)
148+
expect(toJsonSchema).toHaveBeenCalledTimes(2)
149+
})
150+
151+
it('converts on every call when disabled', () => {
152+
const schema = type('string')
153+
const toJsonSchema = vi.spyOn(schema, 'toJsonSchema')
154+
toJsonSchema.mockClear()
155+
156+
const first = converter.convert(schema, 'input')
157+
const second = converter.convert(schema, 'input')
158+
159+
expect(second).toEqual(first)
160+
expect(second).not.toBe(first)
161+
expect(toJsonSchema).toHaveBeenCalledTimes(2)
162+
})
163+
})
130164
})

packages/arktype/src/converter.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,28 @@ import type { AnySchema, JsonSchema, JsonSchemaConverter, JsonSchemaConverterDir
33
import type { Type } from 'arktype'
44
import { JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema'
55

6-
export interface ArkTypeToJsonSchemaConverterOptions extends Omit<ToJsonSchema.Options, 'dialect' | 'target'> {}
6+
export interface ArkTypeToJsonSchemaConverterOptions extends Omit<ToJsonSchema.Options, 'dialect' | 'target'> {
7+
/**
8+
* Caches conversion results in a WeakMap keyed by the ArkType schema instance,
9+
* so converting the same schema again is free.
10+
*
11+
* When enabled, repeated conversions return the same JSON schema object,
12+
* so treat returned schemas as immutable.
13+
*
14+
* @default false
15+
*/
16+
cache?: boolean
17+
}
718

819
export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter {
920
private readonly toJsonSchemaOptions: ToJsonSchema.Options
21+
private readonly cache: undefined | { [d in JsonSchemaConverterDirection]: WeakMap<Type, [jsonSchema: JsonSchema, optional: boolean]> }
22+
23+
constructor({ cache, ...options }: ArkTypeToJsonSchemaConverterOptions = {}) {
24+
if (cache) {
25+
this.cache = { input: new WeakMap(), output: new WeakMap() }
26+
}
1027

11-
constructor(options: ArkTypeToJsonSchemaConverterOptions = {}) {
1228
this.toJsonSchemaOptions = {
1329
...options,
1430
target: 'draft-2020-12',
@@ -49,6 +65,23 @@ export class ArkTypeToJsonSchemaConverter implements JsonSchemaConverter {
4965
convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] {
5066
const arkTypeSchema = schema as Type
5167

68+
if (this.cache) {
69+
const cached = this.cache[direction].get(arkTypeSchema)
70+
if (cached) {
71+
return cached
72+
}
73+
}
74+
75+
const result = this.convertUncached(arkTypeSchema, direction)
76+
77+
if (this.cache) {
78+
this.cache[direction].set(arkTypeSchema, result)
79+
}
80+
81+
return result
82+
}
83+
84+
private convertUncached(arkTypeSchema: Type, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] {
5285
const jsonSchema = this.convertArkType(arkTypeSchema, direction)
5386

5487
let optional = false

packages/valibot/src/converter.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1+
import { toJsonSchema } from '@valibot/to-json-schema'
12
import * as v from 'valibot'
23
import * as z from 'zod'
34
import { ValibotToJsonSchemaConverter } from './converter'
45

6+
vi.mock('@valibot/to-json-schema', async (original) => {
7+
const mod = await original<typeof import('@valibot/to-json-schema')>()
8+
return {
9+
...mod,
10+
toJsonSchema: vi.fn((...args: [any]) => mod.toJsonSchema(...args)),
11+
}
12+
})
13+
514
describe('valibotToJsonSchemaConverter', () => {
615
const converter = new ValibotToJsonSchemaConverter()
716

@@ -114,4 +123,39 @@ describe('valibotToJsonSchemaConverter', () => {
114123
expect(converter.convert(schema, 'input')).toEqual([jsonSchema, false])
115124
})
116125
})
126+
127+
describe('cache option', () => {
128+
const schema = v.pipe(v.number(), v.transform(n => n.toString()), v.string())
129+
130+
it('reuses conversion results per schema and direction when enabled', () => {
131+
const converter = new ValibotToJsonSchemaConverter({ cache: true })
132+
133+
vi.mocked(toJsonSchema).mockClear()
134+
135+
const input = converter.convert(schema, 'input')
136+
expect(input).toEqual([{ type: 'number' }, false])
137+
expect(converter.convert(schema, 'input')).toBe(input)
138+
expect(toJsonSchema).toHaveBeenCalledTimes(1)
139+
140+
const output = converter.convert(schema, 'output')
141+
expect(output).toEqual([{ type: 'string' }, false])
142+
expect(output).not.toBe(input)
143+
expect(converter.convert(schema, 'output')).toBe(output)
144+
expect(toJsonSchema).toHaveBeenCalledTimes(2)
145+
146+
converter.convert(v.string(), 'input')
147+
expect(toJsonSchema).toHaveBeenCalledTimes(3)
148+
})
149+
150+
it('converts on every call when disabled', () => {
151+
vi.mocked(toJsonSchema).mockClear()
152+
153+
const first = converter.convert(schema, 'input')
154+
const second = converter.convert(schema, 'input')
155+
156+
expect(second).toEqual(first)
157+
expect(second).not.toBe(first)
158+
expect(toJsonSchema).toHaveBeenCalledTimes(2)
159+
})
160+
})
117161
})

packages/valibot/src/converter.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,31 @@ import type { BaseSchema, MapSchema, SetSchema } from 'valibot'
44
import { JsonSchemaFormat, JsonSchemaXNativeType } from '@orpc/json-schema'
55
import { toJsonSchema } from '@valibot/to-json-schema'
66

7-
export interface ValibotToJsonSchemaConverterOptions extends Omit<ConversionConfig, 'target' | 'typeMode' | 'overrideRef'> {}
7+
export interface ValibotToJsonSchemaConverterOptions extends Omit<ConversionConfig, 'target' | 'typeMode' | 'overrideRef'> {
8+
/**
9+
* Caches conversion results in a WeakMap keyed by the Valibot schema instance,
10+
* so converting the same schema again is free.
11+
*
12+
* When enabled, repeated conversions return the same JSON schema object,
13+
* so treat returned schemas as immutable.
14+
*
15+
* @default false
16+
*/
17+
cache?: boolean
18+
}
819

920
export class ValibotToJsonSchemaConverter implements JsonSchemaConverter {
10-
constructor(private readonly options: ValibotToJsonSchemaConverterOptions = {}) {
21+
private readonly conversionConfig: ConversionConfig
22+
private readonly cache: undefined | { [d in JsonSchemaConverterDirection]: WeakMap<BaseSchema<any, any, any>, [jsonSchema: JsonSchema, optional: boolean]> }
23+
24+
constructor(
25+
{ cache, ...conversionConfig }: ValibotToJsonSchemaConverterOptions = {},
26+
) {
27+
this.conversionConfig = conversionConfig
28+
29+
if (cache) {
30+
this.cache = { input: new WeakMap(), output: new WeakMap() }
31+
}
1132
}
1233

1334
condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean {
@@ -16,6 +37,24 @@ export class ValibotToJsonSchemaConverter implements JsonSchemaConverter {
1637

1738
convert(schema: AnySchema | undefined, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] {
1839
const valibotSchema = schema as BaseSchema<any, any, any>
40+
41+
if (this.cache) {
42+
const cached = this.cache[direction].get(valibotSchema)
43+
if (cached) {
44+
return cached
45+
}
46+
}
47+
48+
const result = this.convertUncached(valibotSchema, direction)
49+
50+
if (this.cache) {
51+
this.cache[direction].set(valibotSchema, result)
52+
}
53+
54+
return result
55+
}
56+
57+
private convertUncached(valibotSchema: BaseSchema<any, any, any>, direction: JsonSchemaConverterDirection): [jsonSchema: JsonSchema, optional: boolean] {
1958
const jsonSchema = this.convertValibot(valibotSchema, direction)
2059

2160
let optional = false
@@ -33,7 +72,7 @@ export class ValibotToJsonSchemaConverter implements JsonSchemaConverter {
3372
private convertValibot(schema: BaseSchema<any, any, any>, direction: JsonSchemaConverterDirection): ValibotJsonSchema {
3473
const jsonSchema = toJsonSchema(schema, {
3574
errorMode: 'ignore',
36-
...this.options,
75+
...this.conversionConfig,
3776
target: 'draft-2020-12',
3877
typeMode: direction,
3978
overrideSchema: (context) => {
@@ -70,8 +109,8 @@ export class ValibotToJsonSchemaConverter implements JsonSchemaConverter {
70109
;(context.jsonSchema as any)['x-native-type'] = JsonSchemaXNativeType.Map
71110
}
72111

73-
if (this.options.overrideSchema) {
74-
return this.options.overrideSchema(context)
112+
if (this.conversionConfig.overrideSchema) {
113+
return this.conversionConfig.overrideSchema(context)
75114
}
76115
},
77116
})

packages/zod/src/converter.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,37 @@ describe('zodToJsonSchemaConverter', () => {
288288
}, false])
289289
})
290290
})
291+
292+
describe('cache option', () => {
293+
it('reuses conversion results per schema and direction when enabled', () => {
294+
const converter = new ZodToJsonSchemaConverter({ cache: true })
295+
296+
vi.mocked(toJSONSchema).mockClear()
297+
298+
const input = converter.convert(codecSchema, 'input')
299+
expect(input).toEqual([{ type: 'string' }, false])
300+
expect(converter.convert(codecSchema, 'input')).toBe(input)
301+
expect(toJSONSchema).toHaveBeenCalledTimes(1)
302+
303+
const output = converter.convert(codecSchema, 'output')
304+
expect(output).toEqual([{ type: 'number' }, false])
305+
expect(output).not.toBe(input)
306+
expect(converter.convert(codecSchema, 'output')).toBe(output)
307+
expect(toJSONSchema).toHaveBeenCalledTimes(2)
308+
309+
converter.convert(z.string(), 'input')
310+
expect(toJSONSchema).toHaveBeenCalledTimes(3)
311+
})
312+
313+
it('converts on every call when disabled', () => {
314+
vi.mocked(toJSONSchema).mockClear()
315+
316+
const first = converter.convert(codecSchema, 'input')
317+
const second = converter.convert(codecSchema, 'input')
318+
319+
expect(second).toEqual(first)
320+
expect(second).not.toBe(first)
321+
expect(toJSONSchema).toHaveBeenCalledTimes(2)
322+
})
323+
})
291324
})

0 commit comments

Comments
 (0)