Skip to content

Commit 20c02f1

Browse files
z4o4zclaude
andauthored
fix(zod): emit recursive reusable schemas with full TypeScript types (orval-labs#3467)
* fix(zod): emit recursive reusable schemas with full TypeScript types A self-referential reusable schema is generated as a `const X = ...zod.lazy( () => X)...` that reads its own binding inside its initializer. Under strict / noImplicitAny TypeScript rejects this with TS7022 ("'X' implicitly has type 'any' ... referenced directly or indirectly in its own initializer"). Detect schemas that sit in a cycle (SCC > 1 or a self-loop) and, for those, generate the recursive TS type with orval's own model generator (`resolveValue`, the same path that produces `export type X` in the model output, so identifiers line up via `getRefInfo`) and pin the schema to it: export type X = ...X[]...; export const X: zod.ZodType<X> = zod.union([...zod.lazy(() => X)...]); The annotation both satisfies the compiler and preserves full `z.infer` typing through the recursion, instead of collapsing recursive positions to `unknown`. Acyclic schemas are unchanged (still derive `zod.input<typeof X>`). Applies to both the inline single-file and per-file reusable writers; verified on zod v3 and v4 incl. mutual recursion, coercion + defaults, and sanitized names. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(zod): address review on recursive reusable schemas - resolve the recursive schema lookup key via getRefInfo/isComponentRef instead of a blind ref slice (decodes JSON Pointer escapes, guards the #/components/schemas/ prefix before indexing components.schemas) - make the recursive-type assertion whitespace-tolerant (regex) - drop the Record<string, unknown> cast in reusable-schema tests; the typed override.zod shape already carries generateReusableSchemas Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14d5a0f commit 20c02f1

5 files changed

Lines changed: 277 additions & 21 deletions

File tree

packages/orval/src/generate-spec.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,110 @@ describe('generateSpec - generateReusableSchemas inline (single mode)', () => {
162162
}
163163
});
164164
});
165+
166+
describe('generateSpec - generateReusableSchemas recursive ($ref to self)', () => {
167+
// Regression: a self-referential component schema is emitted as a single
168+
// reusable `const` whose initializer references itself through `zod.lazy`.
169+
// TypeScript rejects such a `const` under strict / noImplicitAny with TS7022
170+
// ("'X' implicitly has type 'any' ... referenced directly or indirectly in
171+
// its own initializer"). The writer fixes this by generating the recursive
172+
// TS type and pinning the schema to it: `const X: zod.ZodType<X>`. That both
173+
// satisfies the compiler and preserves full `z.infer` typing through the
174+
// recursion (rather than collapsing recursive positions to `unknown`).
175+
const RECURSIVE_SPEC: OpenApiDocument = {
176+
openapi: '3.1.0',
177+
info: { title: 'Recursive', version: '1.0.0' },
178+
paths: {
179+
'/values': {
180+
get: {
181+
operationId: 'listValues',
182+
responses: {
183+
'200': {
184+
description: 'ok',
185+
content: {
186+
'application/json': {
187+
schema: {
188+
type: 'array',
189+
items: { $ref: '#/components/schemas/JsonValue' },
190+
},
191+
},
192+
},
193+
},
194+
},
195+
},
196+
},
197+
},
198+
components: {
199+
schemas: {
200+
JsonValue: {
201+
anyOf: [
202+
{
203+
anyOf: [
204+
{ type: 'string' },
205+
{ type: 'number' },
206+
{ type: 'boolean' },
207+
],
208+
},
209+
{
210+
type: 'array',
211+
items: { $ref: '#/components/schemas/JsonValue' },
212+
},
213+
{
214+
type: 'object',
215+
additionalProperties: { $ref: '#/components/schemas/JsonValue' },
216+
},
217+
],
218+
},
219+
},
220+
},
221+
};
222+
223+
it('pins the recursive schema to a generated TS type so it type-checks with full inference', async () => {
224+
const workspace = await createTempWorkspace();
225+
const targetFile = path.join(workspace, 'zod.ts');
226+
227+
try {
228+
const options = await normalizeOptions(
229+
{
230+
input: { target: RECURSIVE_SPEC },
231+
output: {
232+
target: './zod.ts',
233+
mode: 'single',
234+
client: 'zod',
235+
override: { zod: { generateReusableSchemas: true } },
236+
},
237+
},
238+
workspace,
239+
);
240+
241+
await generateSpec(workspace, options);
242+
243+
const content = await fs.readFile(targetFile, 'utf8');
244+
245+
// A recursive TS type is generated for the schema. Asserted with a
246+
// whitespace-tolerant regex (union-bar spacing and index-signature brace
247+
// spacing are formatter-dependent) that still pins the structure: the
248+
// union of the four primitives plus the self-referential array and the
249+
// index signature whose value type is `JsonValue`.
250+
expect(content).toMatch(
251+
/export type JsonValue\s*=\s*string\s*\|\s*number\s*\|\s*boolean\s*\|\s*JsonValue\[\]\s*\|\s*\{\s*\[key:\s*string\]:\s*JsonValue\s*\}/,
252+
);
253+
// ...and the schema const is pinned to it (the fix for TS7022).
254+
expect(content).toContain(
255+
'export const JsonValue: zod.ZodType<JsonValue> = zod.union(',
256+
);
257+
// The self-reference is a plain lazy (the const annotation breaks the
258+
// self-inference cycle, so no callback annotation is needed).
259+
expect(content).toContain('zod.lazy(() => JsonValue)');
260+
// The recursive schema must NOT re-derive its type from itself via
261+
// `zod.input<typeof JsonValue>` — that alias would be circular.
262+
expect(content).not.toContain(
263+
'export type JsonValue = zod.input<typeof JsonValue>',
264+
);
265+
// No unresolved sentinels.
266+
expect(content).not.toContain('__REF_');
267+
} finally {
268+
await rm(workspace, { recursive: true, force: true });
269+
}
270+
});
271+
});

packages/orval/src/reusable-schemas.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ describe('rewriteReusableSchemas', () => {
257257
expect(pet?.zod).toBe('zod.object({ owner: owner })');
258258
// Topological order: owner emitted before pet.
259259
expect(result.map((e) => e.name)).toEqual(['owner', 'pet']);
260+
// Acyclic schemas are not flagged recursive.
261+
expect(result.every((e) => e.isRecursive !== true)).toBe(true);
260262
});
261263

262264
it('wraps cycle edges in z.lazy(() => Name)', () => {
@@ -283,6 +285,10 @@ describe('rewriteReusableSchemas', () => {
283285
s?.includes('zod.lazy'),
284286
).length;
285287
expect(lazyCount).toBe(1);
288+
// Both members of the cycle are flagged recursive so the writer pins each
289+
// to `zod.ZodType<Name>` (the const-level fix for TS7022).
290+
expect(a?.isRecursive).toBe(true);
291+
expect(b?.isRecursive).toBe(true);
286292
});
287293

288294
it('wraps self-loops in z.lazy', () => {
@@ -297,5 +303,7 @@ describe('rewriteReusableSchemas', () => {
297303
];
298304
const result = rewriteReusableSchemas(entries);
299305
expect(result[0].zod).toBe('zod.object({ child: zod.lazy(() => node) })');
306+
// Self-loop ⇒ recursive; the writer annotates `const node: zod.ZodType<node>`.
307+
expect(result[0].isRecursive).toBe(true);
300308
});
301309
});

packages/orval/src/reusable-schemas.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ export interface ReusableSchemaEntry {
122122
zod: string;
123123
consts: string;
124124
usedRefs: Set<string>;
125+
/**
126+
* True when this schema references itself directly or transitively (its node
127+
* sits in a cycle: an SCC of size > 1, or a self-loop). Such a schema is
128+
* emitted as a recursive `const` that reads its own binding inside its
129+
* initializer, so the writer must give it an explicit type annotation
130+
* (`const X: zod.ZodType<X>`) to avoid TS7022. Set by
131+
* {@link rewriteReusableSchemas}; `undefined`/`false` for acyclic schemas.
132+
*/
133+
isRecursive?: boolean;
125134
}
126135

127136
export interface GenerateReusableSchemaSetOptions {
@@ -307,6 +316,15 @@ export const rewriteSentinelsToDirect = (zod: string): string =>
307316
* reorder entries so that every non-lazy reference is emitted AFTER its
308317
* target. This avoids TDZ errors at module load.
309318
*
319+
* Entries that sit in a cycle (SCC of size > 1, or a self-loop) are flagged
320+
* `isRecursive`. Their generated `const` reads its own binding inside the
321+
* initializer (through the `zod.lazy` wrapper), which TypeScript rejects with
322+
* TS7022 ("'X' implicitly has type 'any' ... referenced directly or indirectly
323+
* in its own initializer") unless the `const` carries an explicit type
324+
* annotation. The writer (`write-zod-specs`) supplies that annotation —
325+
* `const X: zod.ZodType<X>` — backed by a generated TS type, which both
326+
* silences TS7022 and preserves full `z.infer` typing through the recursion.
327+
*
310328
* Both the lazy classification and the emit order come from a single Tarjan
311329
* run, guaranteeing they agree: a non-lazy edge u→v means v is visited (and
312330
* popped) before u in DFS, so v appears earlier in the SCC array → emitted
@@ -329,6 +347,17 @@ export const rewriteReusableSchemas = (
329347

330348
const { sccs, lazyEdges } = tarjan(graph);
331349

350+
// A node is recursive iff it sits in a cycle: either an SCC with more than
351+
// one member (mutual recursion), or a single-node SCC that has a self-loop.
352+
const recursiveNames = new Set<string>();
353+
for (const scc of sccs) {
354+
if (scc.length > 1) {
355+
for (const name of scc) recursiveNames.add(name);
356+
} else if (lazyEdges.has(edgeKey(scc[0], scc[0]))) {
357+
recursiveNames.add(scc[0]);
358+
}
359+
}
360+
332361
const rewritten = new Map(
333362
entries.map((entry) => {
334363
const newZod = entry.zod.replaceAll(
@@ -338,7 +367,10 @@ export const rewriteReusableSchemas = (
338367
return isLazy ? `zod.lazy(() => ${refName})` : refName;
339368
},
340369
);
341-
return [entry.name, { ...entry, zod: newZod }] as const;
370+
return [
371+
entry.name,
372+
{ ...entry, zod: newZod, isRecursive: recursiveNames.has(entry.name) },
373+
] as const;
342374
}),
343375
);
344376

packages/orval/src/write-zod-specs.test.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,17 @@ const createOutputOptions = (): Parameters<typeof writeZodSchemas>[4] =>
2929
namingConvention: 'PascalCase',
3030
indexFiles: true,
3131
override: {
32+
// Mirrors the normalized defaults the real pipeline supplies; the
33+
// recursive-schema TS-type generation (`resolveValue`) reads these.
34+
components: {
35+
schemas: { suffix: '', itemSuffix: 'Item' },
36+
},
3237
zod: {
3338
strict: {
3439
body: true,
3540
},
3641
generate: {
42+
param: true,
3743
body: true,
3844
query: true,
3945
header: true,
@@ -355,8 +361,7 @@ describe('writeZodSchemas with generateReusableSchemas', () => {
355361
} satisfies Parameters<typeof writeZodSchemas>[0];
356362

357363
const options = createOutputOptions();
358-
(options.override.zod as Record<string, unknown>).generateReusableSchemas =
359-
true;
364+
options.override.zod.generateReusableSchemas = true;
360365

361366
await writeZodSchemas(builder, schemasPath, '.ts', '', options);
362367

@@ -402,8 +407,7 @@ describe('writeZodSchemas with generateReusableSchemas', () => {
402407
} satisfies Parameters<typeof writeZodSchemas>[0];
403408

404409
const options = createOutputOptions();
405-
(options.override.zod as Record<string, unknown>).generateReusableSchemas =
406-
true;
410+
options.override.zod.generateReusableSchemas = true;
407411

408412
await writeZodSchemas(builder, schemasPath, '.ts', '', options);
409413

@@ -420,6 +424,64 @@ describe('writeZodSchemas with generateReusableSchemas', () => {
420424

421425
await fs.remove(root);
422426
});
427+
428+
it('pins recursive schemas to a generated TS type across files', async () => {
429+
const root = await fs.mkdtemp(path.join(tmpdir(), 'orval-zod-reuse-rec-'));
430+
const schemasPath = path.join(root, 'schemas');
431+
432+
// Node <-> Edge mutual recursion: the back-edge is emitted as a `zod.lazy`,
433+
// so each `const` reads (transitively) its own binding and needs an
434+
// explicit `zod.ZodType<...>` annotation to satisfy TS7022.
435+
const builder = {
436+
spec: {
437+
components: {
438+
schemas: {
439+
Node: {
440+
type: 'object',
441+
properties: {
442+
edges: {
443+
type: 'array',
444+
items: { $ref: '#/components/schemas/Edge' },
445+
},
446+
},
447+
required: ['edges'],
448+
},
449+
Edge: {
450+
type: 'object',
451+
properties: { to: { $ref: '#/components/schemas/Node' } },
452+
required: ['to'],
453+
},
454+
},
455+
},
456+
},
457+
target: '',
458+
schemas: [
459+
{ name: 'Node', schema: { $ref: '#/components/schemas/Node' } },
460+
{ name: 'Edge', schema: { $ref: '#/components/schemas/Edge' } },
461+
],
462+
} satisfies Parameters<typeof writeZodSchemas>[0];
463+
464+
const options = createOutputOptions();
465+
options.override.zod.generateReusableSchemas = true;
466+
467+
await writeZodSchemas(builder, schemasPath, '.ts', '', options);
468+
469+
const nodeContent = await fs.readFile(
470+
path.join(schemasPath, 'Node.ts'),
471+
'utf8',
472+
);
473+
474+
// The recursive TS type is generated and the const is pinned to it.
475+
expect(nodeContent).toContain('export type Node = ');
476+
expect(nodeContent).toContain('export const Node: zod.ZodType<Node> = ');
477+
// Cross-file reference to Edge is imported (so the generated type resolves).
478+
expect(nodeContent).toMatch(/from '\.\/Edge'/);
479+
// The acyclic `zod.input<typeof Node>` alias would be circular here.
480+
expect(nodeContent).not.toContain('export type Node = zod.input<');
481+
expect(nodeContent).not.toContain('__REF_');
482+
483+
await fs.remove(root);
484+
});
423485
});
424486

425487
describe('writeZodSchemasFromVerbs with generateReusableSchemas', () => {
@@ -444,8 +506,7 @@ describe('writeZodSchemasFromVerbs with generateReusableSchemas', () => {
444506
} as never;
445507

446508
const options = createOutputOptions();
447-
(options.override.zod as Record<string, unknown>).generateReusableSchemas =
448-
true;
509+
options.override.zod.generateReusableSchemas = true;
449510
const ctx = {
450511
output: {
451512
override: {
@@ -515,8 +576,7 @@ describe('writeZodSchemasFromVerbs with generateReusableSchemas', () => {
515576
// camelCase namingConvention → file names are camelCased (`petStatus.ts`),
516577
// but the exported identifier is always PascalCase (`PetStatus`).
517578
(options as { namingConvention: string }).namingConvention = 'camelCase';
518-
(options.override.zod as Record<string, unknown>).generateReusableSchemas =
519-
true;
579+
options.override.zod.generateReusableSchemas = true;
520580
const ctx = {
521581
output: {
522582
override: {

0 commit comments

Comments
 (0)