fix(zod): emit recursive reusable schemas with full TypeScript types - #3467
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughDetects recursive component schemas (SCC/self-loop), marks rewritten reusable-schema entries with ChangesRecursive Zod Schema Handling
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds explicit handling for recursive reusable Zod schemas by generating a non-circular TypeScript type (via resolveValue) and pinning the Zod constant to that type to avoid TS7022 in strict mode.
Changes:
- Introduce a shared
renderReusableSchemaEntryhelper to render reusable schema exports for both inline and per-file generation. - Detect recursive schemas during reusable-schema rewriting and mark entries with
isRecursive. - Add regression tests covering self-recursive and mutually-recursive schemas (single-file and per-file reusable modes).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/orval/src/write-zod-specs.ts | Centralizes reusable-schema rendering and adds a recursive-schema export strategy using resolveValue + zod.ZodType<T> pinning. |
| packages/orval/src/write-zod-specs.test.ts | Updates test options to match normalized defaults and adds per-file recursion regression test. |
| packages/orval/src/reusable-schemas.ts | Flags entries that are part of cycles (isRecursive) based on Tarjan SCC output. |
| packages/orval/src/reusable-schemas.test.ts | Asserts isRecursive is set for cycles and not set for acyclic schemas. |
| packages/orval/src/generate-spec.test.ts | Adds end-to-end regression coverage for self-recursive component schema output in single-file mode. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return ( | ||
| `${consts}export type ${entry.name} = ${typeBody};\n\n` + | ||
| `export const ${entry.name}: zod.ZodType<${entry.name}> = ${entry.zod};\n\n` + | ||
| `export type ${entry.name}Output = zod.output<typeof ${entry.name}>;` | ||
| ); |
There was a problem hiding this comment.
Leaving the divergence as-is, with reasoning:
On the zod.input<typeof Name> form for recursive entries — not possible. That's the bug this PR fixes. The recursive const is pinned with an explicit annotation (const Name: zod.ZodType<Name>) precisely because its initializer reads its own binding through zod.lazy. Deriving type Name = zod.input<typeof Name> would reference Name inside the type the const is annotated with, reintroducing the self-inference cycle TypeScript rejects as TS7022 (covered by the assertions at generate-spec.test.ts:258 and write-zod-specs.test.ts:483, which assert that alias is NOT emitted). The same circularity defeats a NameInput = zod.input<typeof Name> alias, so renaming to NameInput wouldn't resolve it either.
On 'export inconsistency breaking downstream' — the exported surface is actually the same in both branches: each emits export type Name and export type NameOutput (write-zod-specs.ts:201-205 vs 208-211). What differs is only the RHS derivation of type Name (explicit OpenAPI type vs zod.input), forced by the constraint above. Consumers referencing Name/NameOutput resolve in both cases, so there's no break. Switching non-recursive entries to NameInput/NameOutput would itself be a breaking rename of the established acyclic output, outside this bugfix's scope.
On the 3-generic zod.ZodType<Output, Def, Input> for coercion/preprocess/transform — this is a real but narrow accuracy gap (recursive schema AND coercion). Fully fixing it needs orval to generate two TS type variants (input-shaped and output-shaped) for the recursive schema; today resolveValue emits a single type per schema, so this is a feature addition rather than a tweak to this PR, whose goal is making recursive schemas type-check at all (they were unknown/broken before). Happy to file a follow-up if the maintainers want input/output-variant generation for that intersection — flagging it rather than expanding scope here.
- 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>
Summary
Follow-up to #3465. When
override.zod.generateReusableSchemasis enabled, a self-referential component schema is generated as aconst X = ...zod.lazy(() => X)...that reads its own binding inside its initializer. Understrict/noImplicitAny, TypeScript rejects this with TS7022:This PR detects schemas that sit in a dependency cycle (a Tarjan SCC of size > 1, or a self-loop) and, for those, generates the recursive TS type with orval's own model generator (
resolveValue— the same path that producesexport type Xin the model output, so identifiers line up viagetRefInfo) and pins the schema to it:The
zod.ZodType<JsonValue>annotation both silences TS7022 and preserves fullz.infertyping through the recursion — previously the recursive positions would have collapsed tounknown. Acyclic schemas are unchanged (they keep derivingzod.input<typeof X>).Applies to both reusable writers: inline single-file (
mode: 'single',client: 'zod') and per-file (schemas: { type: 'zod' }).Test plan
rewriteReusableSchemasflags cyclic/self-loop entriesisRecursive(and leaves acyclic ones unflagged)generate-spec): recursive schema emitsexport type X+const X: zod.ZodType<X>+ plainzod.lazy(() => X), and no circularzod.input<typeof X>aliaswrite-zod-specs): mutual recursion (Node↔Edge) pins each const to its generated type with cross-file importsstricton zod v3 and v4 across:anyOfJSON-value, object with coercion + defaults, mutual recursion, and sanitized names (__schema0→_Schema0)lint,typecheck, and fullorvalsuite (119 tests) pass🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features / Refactor
Tests
Documentation