Skip to content

fix(zod): emit recursive reusable schemas with full TypeScript types - #3467

Merged
melloware merged 2 commits into
orval-labs:masterfrom
z4o4z:fix/reusable-zod-recursive-types
May 27, 2026
Merged

fix(zod): emit recursive reusable schemas with full TypeScript types#3467
melloware merged 2 commits into
orval-labs:masterfrom
z4o4z:fix/reusable-zod-recursive-types

Conversation

@z4o4z

@z4o4z z4o4z commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #3465. When override.zod.generateReusableSchemas is enabled, a self-referential component 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' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.

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 produces export type X in the model output, so identifiers line up via getRefInfo) and pins the schema to it:

export type JsonValue = string | number | boolean | JsonValue[] | {[key: string]: JsonValue};

export const JsonValue: zod.ZodType<JsonValue> = zod.union([
  zod.union([zod.string(), zod.number(), zod.boolean()]),
  zod.array(zod.lazy(() => JsonValue)),
  zod.record(zod.string(), zod.lazy(() => JsonValue)),
]);

export type JsonValueOutput = zod.output<typeof JsonValue>;

The zod.ZodType<JsonValue> annotation both silences TS7022 and preserves full z.infer typing through the recursion — previously the recursive positions would have collapsed to unknown. Acyclic schemas are unchanged (they keep deriving zod.input<typeof X>).

Applies to both reusable writers: inline single-file (mode: 'single', client: 'zod') and per-file (schemas: { type: 'zod' }).

Test plan

  • New unit tests: rewriteReusableSchemas flags cyclic/self-loop entries isRecursive (and leaves acyclic ones unflagged)
  • New e2e test (generate-spec): recursive schema emits export type X + const X: zod.ZodType<X> + plain zod.lazy(() => X), and no circular zod.input<typeof X> alias
  • New per-file test (write-zod-specs): mutual recursion (NodeEdge) pins each const to its generated type with cross-file imports
  • Generated output type-checks under strict on zod v3 and v4 across: anyOf JSON-value, object with coercion + defaults, mutual recursion, and sanitized names (__schema0_Schema0)
  • lint, typecheck, and full orval suite (119 tests) pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of recursive and mutually recursive component schemas: generated schemas are now properly typed to avoid TypeScript errors and circular type aliases.
  • New Features / Refactor

    • Centralized reusable-schema rendering to produce consistent, cross-file pinned types for recursive schemas.
  • Tests

    • Added and updated regression/unit tests to cover recursive schema detection and generation behavior.
  • Documentation

    • Expanded inline docs explaining recursive-schema handling and emitted type annotations.

Review Change Stack

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>
Copilot AI review requested due to automatic review settings May 27, 2026 16:56
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9592ec86-7ce0-44aa-99b8-9c1e70b1b9e4

📥 Commits

Reviewing files that changed from the base of the PR and between 3f917d5 and ae78de6.

📒 Files selected for processing (3)
  • packages/orval/src/generate-spec.test.ts
  • packages/orval/src/write-zod-specs.test.ts
  • packages/orval/src/write-zod-specs.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/orval/src/write-zod-specs.ts
  • packages/orval/src/write-zod-specs.test.ts
  • packages/orval/src/generate-spec.test.ts

📝 Walkthrough

Walkthrough

Detects recursive component schemas (SCC/self-loop), marks rewritten reusable-schema entries with isRecursive, centralizes rendering into renderReusableSchemaEntry which pins recursive schemas to generated TypeScript types, and adds unit + integration tests validating lazy wrappers, cross-file imports, and absence of __REF_ sentinels.

Changes

Recursive Zod Schema Handling

Layer / File(s) Summary
Recursive schema contract and detection
packages/orval/src/reusable-schemas.ts
ReusableSchemaEntry adds optional isRecursive?: boolean. rewriteReusableSchemas computes recursion via Tarjan SCCs and self-loop detection and sets isRecursive for multi-node cycles or self-referential nodes.
Zod schema rendering helper with recursive type pinning
packages/orval/src/write-zod-specs.ts
Adds renderReusableSchemaEntry(entry, context) and imports (resolveValue, ReusableSchemaEntry). Renders const + companion zod.input/zod.output; pins runtime schema as zod.ZodType<RecursiveType> when entry.isRecursive. Used by inline and per-file generation paths.
Unit tests for recursion detection
packages/orval/src/reusable-schemas.test.ts
Tests assert isRecursive behavior: DAG entries are not recursive; 2-node cycles mark both entries; self-loops mark the entry as recursive.
Integration tests for emitted recursive schemas
packages/orval/src/generate-spec.test.ts, packages/orval/src/write-zod-specs.test.ts
Adds tests verifying self-referential JsonValue and mutually recursive Node/Edge output: zod.lazy used for references, recursive schemas pinned to zod.ZodType<...> with cross-file imports, and no __REF_ sentinels or circular zod.input<typeof ...> aliases. Also adjusts test option defaults to enable generateReusableSchemas and component naming defaults.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • orval-labs/orval#3464: Addresses unresolved __REF_<name>__ sentinels in per-operation Zod schema files.

Suggested labels

zod, bug

Suggested reviewers

  • snebjorn
  • melloware
  • soartec-lab

Poem

🐰 In tangled loops of schema trees I hop,
I mark the cycles, pin types at the top,
With lazy wraps I mend each recursive seam,
No sentinels linger, no circular dream.
Hooray — Zod and TypeScript sleep soundly now! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: emitting recursive reusable Zod schemas with full TypeScript types to fix TS7022 errors.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 renderReusableSchemaEntry helper 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.

Comment thread packages/orval/src/write-zod-specs.ts Outdated
Comment on lines +189 to +193
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}>;`
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/orval/src/generate-spec.test.ts Outdated
Comment thread packages/orval/src/write-zod-specs.test.ts Outdated
- 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>
@melloware melloware added the zod Zod schema client related issue label May 27, 2026
@melloware melloware added this to the 8.14.0 milestone May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

zod Zod schema client related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants