Skip to content

fix(openapi-zod-ts): assert recursive lazy schemas as z.ZodType<T> - #398

Merged
benjamineckstein merged 1 commit into
mainfrom
fix/397-recursive-lazy-zodtype-assertion
Jun 24, 2026
Merged

fix(openapi-zod-ts): assert recursive lazy schemas as z.ZodType<T>#398
benjamineckstein merged 1 commit into
mainfrom
fix/397-recursive-lazy-zodtype-assertion

Conversation

@benjamineckstein

@benjamineckstein benjamineckstein commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Asserts recursive lazy schemas generated by openapi-zod-ts are properly typed as z.ZodType<T> rather than falling back to unknown, ensuring type safety for complex nested OpenAPI structures.

Changes

  • Added type assertion for recursive lazy Zod schemas
  • Improves TypeScript inference for deeply nested object types
  • Prevents unknown type fallbacks in recursive type definitions

Testing

  • Matrix test coverage validates all 128 example specs
  • Ensure generated types pass strict TypeScript checking
  • Verify no unknown types in inferred recursive structures

References

Summary by CodeRabbit

  • Bug Fixes
    • Improved generated schema output for recursive and cyclic models, making it more reliable across TypeScript toolchains.
    • Non-recursive models continue to generate without extra type wrapping, keeping output cleaner and more consistent.
    • Updated regression coverage to verify the new schema generation behavior for self-referential and mutually linked types.

Recursive component schemas were emitted with an annotation: const FooSchema: z.ZodType<Foo> = z.lazy(() => z.object({...}).passthrough()). The annotation forces tsc to prove the loose .passthrough() output (which carries an index signature { [x: string]: unknown }) is assignable to the strict generated model Foo. For all-optional recursive cycles, including recursion through an array of $ref or a oneOf-with-null union, older TypeScript or Zod versions cannot prove this and fail with TS2322. It surfaces when --reset-schema re-bootstraps schemas.ts, the only path that re-emits recursive schemas.

Emit the assertion form instead: const FooSchema = z.lazy(() => ...) as z.ZodType<Foo>. An assertion is strictly more permissive than a checked annotation, so it drops the fragile assignability check while keeping z.infer concrete (no unknown regression). Only the recursive branch changes; acyclic schemas stay plain assignments.

Tests assert recursive schemas (self, mutual, through-array, oneOf-null) emit the assertion form and acyclic schemas do not. Regenerated showcase output for canada_holidays (mutually recursive through arrays) reflects the new form.

Closes #397
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The recursive-schema code-generation strategy in generateSchemaDeclaration changes from emitting a TypeScript type annotation (: z.ZodType<T> = z.lazy(...)) to a type assertion (z.lazy(...) as z.ZodType<T>). All unit tests and the integration test for cyclic schemas are updated to match the new expected output.

Changes

Cyclic Schema Assertion Form

Layer / File(s) Summary
Core generator: annotation → assertion form
packages/openapi-zod-ts/src/plugins/zod.ts
generateSchemaDeclaration now emits z.lazy(() => ...) as z.ZodType<ModelType> instead of const Schema: z.ZodType<ModelType> = z.lazy(() => ...). Two nearby doc comments are updated to describe the cast/assertion wording.
Unit tests: self-referential and mutually cyclic schemas
packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts
Updates TreeNode, A/B, Node/Meta, and Author/Book tests to assert assertion form and absence of annotation form; non-circular test now rejects both : z.ZodType and as z.ZodType.
Integration test: bootstrap-run cyclic schemas
packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts
Updates the schemas.ts bootstrap-run regression test to expect as z.ZodType<Holiday/Province> and disallow the old annotation form; acyclic schemas (Error, Region) are verified to remain plain assignments.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Possibly related PRs

  • codewithagents/openapi-zod-ts#385: Modifies the same generateSchemaDeclaration path in zod.ts for cyclic schema typing and updates the same Zod unit tests covering z.ZodType<...> forms.

Poem

🐇 Hoppity hop through the schema maze,
Where cycles once caused annotation haze!
A cast with as now seals the deal,
No strict-assign errors to make you squeal.
The lazy rabbit wraps types just right —
z.lazy(...) leaps through the TypeScript night! 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% 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 clearly summarizes the main change: recursive lazy schemas now use a z.ZodType assertion.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/397-recursive-lazy-zodtype-assertion

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

@github-actions

Copy link
Copy Markdown
Contributor

Fallow audit report

Found 4 findings.

Dependencies (3)
Severity Rule Location Description
minor fallow/unused-dev-dependency examples/package.json:15 Package '@tanstack/react-query' is in devDependencies but never imported; imported in other workspaces: packages/integration, packages/petstore-fastify
minor fallow/unused-dev-dependency examples/package.json:17 Package 'react' is in devDependencies but never imported; imported in other workspaces: packages/integration, packages/petstore-fastify
minor fallow/unused-dev-dependency packages/integration/package.json:24 Package 'fastify' is in devDependencies but never imported; imported in other workspaces: packages/petstore-contract, packages/petstore-fastify
Duplication (1)
Severity Rule Location Description
minor fallow/code-duplication packages/openapi-zod-ts/src/\_\_tests\_\_/zod-unit.test.ts:504 Code clone group 1 (7 lines, 2 instances)

Generated by fallow.

@github-actions github-actions Bot 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.

Fallow audit report

1 inline finding selected for GitHub review.

})

it('non-circular schema does not have a z.ZodType annotation (no regression)', () => {
it('non-circular schema does not have a z.ZodType annotation or assertion (no regression)', () => {

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.

warn fallow/code-duplication: Code clone group 1 (7 lines, 2 instances)

// passthrough object infers an index signature ({ [x: string]: unknown }) that TS cannot
// always prove assignable to the strict generated model interface in those toolchains.
// The assertion form bypasses that check while keeping z.infer concrete and exact.
return `export const ${safeName}Schema = z.lazy(() => ${schemaToZod(schema)}) as z.ZodType<${modelTypeName}>`

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts`:
- Around line 269-275: Strengthen the acyclic-schema regression test in
generator-schema.test.ts by matching the full declaration lines for ErrorSchema
and RegionSchema instead of only checking abbreviated substrings. Update the
expectations around the existing schemas assertions so they verify the actual
generated assignment line from the schema generator, ensuring regressions like a
trailing “as z.ZodType<...>” on ErrorSchema or RegionSchema are caught. Use the
existing schema name checks in the test to locate the affected assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a307b9ff-ac48-40e8-a6a3-88e94f1f79d6

📥 Commits

Reviewing files that changed from the base of the PR and between 0e321d1 and dc997b3.

⛔ Files ignored due to path filters (1)
  • examples/generated/canada_holidays/schemas.ts is excluded by !**/generated/**
📒 Files selected for processing (3)
  • packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts
  • packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts
  • packages/openapi-zod-ts/src/plugins/zod.ts

Comment on lines +269 to +275
// Acyclic schemas (Error, Region) remain plain assignments with no annotation or assertion.
expect(schemas).toContain('ErrorSchema =')
expect(schemas).not.toContain('ErrorSchema: z.ZodType')
expect(schemas).not.toContain('ErrorSchema as z.ZodType')
expect(schemas).toContain('RegionSchema =')
expect(schemas).not.toContain('RegionSchema: z.ZodType')
expect(schemas).not.toContain('RegionSchema as z.ZodType')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen the acyclic-schema regression check.

Line 272 and Line 275 can still pass if generation regresses to ErrorSchema = ... as z.ZodType<...> / RegionSchema = ... as z.ZodType<...>, because the tested substring never occurs in the real declaration. Match the whole declaration line instead of the abbreviated substring.

Suggested fix
     expect(schemas).toContain('ErrorSchema =')
     expect(schemas).not.toContain('ErrorSchema: z.ZodType')
-    expect(schemas).not.toContain('ErrorSchema as z.ZodType')
+    expect(schemas).not.toMatch(/export const ErrorSchema[^\n]*as z\.ZodType/)
     expect(schemas).toContain('RegionSchema =')
     expect(schemas).not.toContain('RegionSchema: z.ZodType')
-    expect(schemas).not.toContain('RegionSchema as z.ZodType')
+    expect(schemas).not.toMatch(/export const RegionSchema[^\n]*as z\.ZodType/)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Acyclic schemas (Error, Region) remain plain assignments with no annotation or assertion.
expect(schemas).toContain('ErrorSchema =')
expect(schemas).not.toContain('ErrorSchema: z.ZodType')
expect(schemas).not.toContain('ErrorSchema as z.ZodType')
expect(schemas).toContain('RegionSchema =')
expect(schemas).not.toContain('RegionSchema: z.ZodType')
expect(schemas).not.toContain('RegionSchema as z.ZodType')
// Acyclic schemas (Error, Region) remain plain assignments with no annotation or assertion.
expect(schemas).toContain('ErrorSchema =')
expect(schemas).not.toContain('ErrorSchema: z.ZodType')
expect(schemas).not.toMatch(/export const ErrorSchema[^\n]*as z\.ZodType/)
expect(schemas).toContain('RegionSchema =')
expect(schemas).not.toContain('RegionSchema: z.ZodType')
expect(schemas).not.toMatch(/export const RegionSchema[^\n]*as z\.ZodType/)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts` around lines
269 - 275, Strengthen the acyclic-schema regression test in
generator-schema.test.ts by matching the full declaration lines for ErrorSchema
and RegionSchema instead of only checking abbreviated substrings. Update the
expectations around the existing schemas assertions so they verify the actual
generated assignment line from the schema generator, ensuring regressions like a
trailing “as z.ZodType<...>” on ErrorSchema or RegionSchema are caught. Use the
existing schema name checks in the test to locate the affected assertions.

@benjamineckstein
benjamineckstein merged commit 0ca96f7 into main Jun 24, 2026
18 checks passed
@benjamineckstein
benjamineckstein deleted the fix/397-recursive-lazy-zodtype-assertion branch June 24, 2026 14:54
@github-actions github-actions Bot mentioned this pull request Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants