Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions examples/generated/canada_holidays/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const ErrorSchema = z.object({
timestamp: z.iso.datetime().optional()
}).passthrough()

export const HolidaySchema: z.ZodType<Holiday> = z.lazy(() => z.object({
export const HolidaySchema = z.lazy(() => z.object({
date: z.iso.date(),
federal: z.union([z.literal(1), z.literal(0)]),
id: z.number().min(1).max(32),
Expand All @@ -37,9 +37,9 @@ export const HolidaySchema: z.ZodType<Holiday> = z.lazy(() => z.object({
observedDate: z.iso.date(),
optional: z.union([z.literal(1)]).optional(),
provinces: z.array(ProvinceSchema).optional()
}).passthrough())
}).passthrough()) as z.ZodType<Holiday>

export const ProvinceSchema: z.ZodType<Province> = z.lazy(() => z.object({
export const ProvinceSchema = z.lazy(() => z.object({
id: z.enum(["AB", "BC", "MB", "NB", "NL", "NS", "NT", "NU", "ON", "PE", "QC", "SK", "YT"]),
nameEn: z.string(),
nameFr: z.string(),
Expand All @@ -48,7 +48,7 @@ export const ProvinceSchema: z.ZodType<Province> = z.lazy(() => z.object({
provinces: z.array(HolidaySchema).optional(),
sourceEn: z.string(),
sourceLink: z.string().regex(new RegExp("https+"))
}).passthrough())
}).passthrough()) as z.ZodType<Province>

// Synthesized schemas for inline JSON responses (operationId-based naming).
// These are used by openapi-server to wire schema.response for Fastify routes.
Expand Down
21 changes: 12 additions & 9 deletions packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,28 +247,31 @@ describe('schema-enhanced mode — cyclic schemas resolve to concrete types (#38
expect(models).toMatch(/(export interface Region \{|export type Region =)/)
})

it('bootstrapped schemas.ts annotates cyclic schemas with their model type, not bare z.ZodType', async () => {
it('bootstrapped schemas.ts uses assertion form for cyclic schemas, not annotation form', async () => {
const { configPath, tmpDir: dir, schemaPath } = await makeConfig(cyclicSchemasFixture)
await generate(dir, configPath) // bootstrap

const schemas = await readFile(schemaPath, 'utf-8')

// Holiday and Province are mutually cyclic: both wrapped in z.lazy() and annotated with
// their concrete model type (imported type-only from models.ts).
expect(schemas).toContain('HolidaySchema: z.ZodType<Holiday>')
expect(schemas).toContain('ProvinceSchema: z.ZodType<Province>')
// Holiday and Province are mutually cyclic: both wrapped in z.lazy() and cast with
// the assertion form `as z.ZodType<T>` so that z.infer resolves to the concrete model.
// The annotation form (: z.ZodType<T> = ...) was fragile for all-optional recursive
// schemas with .passthrough() under older TS/Zod combinations.
expect(schemas).toContain('as z.ZodType<Holiday>')
expect(schemas).toContain('as z.ZodType<Province>')
expect(schemas).not.toContain('HolidaySchema: z.ZodType<Holiday>')
expect(schemas).not.toContain('ProvinceSchema: z.ZodType<Province>')
expect(schemas).toMatch(/import type \{[^}]*\} from '\.\/models\.js'/)

// The bare annotation (which causes unknown) must not appear anywhere.
expect(schemas).not.toMatch(/: z\.ZodType =/)

// No helper interfaces are emitted into the user-owned schemas.ts.
expect(schemas).not.toContain('interface _')

// Acyclic schemas (Error, Region) remain plain assignments with no annotation.
// 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')
Comment on lines +269 to +275

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.

})
})
72 changes: 57 additions & 15 deletions packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ describe('circular / self-referential schemas', () => {
expect(out).toContain('TreeNodeSchema')
})

it('self-referential schema annotation uses z.ZodType<ModelType>, not bare z.ZodType', () => {
it('self-referential schema uses assertion form `as z.ZodType<ModelType>`, not annotation form', () => {
const out = gen({
TreeNode: {
type: 'object',
Expand All @@ -391,9 +391,11 @@ describe('circular / self-referential schemas', () => {
},
},
})
// The annotation references the concrete model type (not bare z.ZodType = ...)
expect(out).toContain('TreeNodeSchema: z.ZodType<TreeNode>')
expect(out).not.toMatch(/: z\.ZodType =/)
// Uses assertion form (as z.ZodType<T>) so passthrough index-signature is not checked
// against the strict model type. Annotation form (: z.ZodType<T> = ...) was fragile for
// all-optional recursive schemas with .passthrough() under older TS/Zod combinations.
expect(out).toContain('as z.ZodType<TreeNode>')
expect(out).not.toContain('TreeNodeSchema: z.ZodType<TreeNode>')
// The model type is imported type-only from models.ts (erased at runtime, no cycle)
expect(out).toMatch(/import type \{ TreeNode \} from '\.\/models\.js'/)
// No local helper interfaces are emitted into the user-owned schemas.ts
Expand All @@ -412,15 +414,16 @@ describe('circular / self-referential schemas', () => {
expect(bDecl?.[0]).toContain('z.lazy(')
})

it('mutually circular schemas carry z.ZodType<ModelType> annotations, not bare z.ZodType', () => {
it('mutually circular schemas use assertion form `as z.ZodType<ModelType>`, not annotation form', () => {
const out = gen({
A: { type: 'object', properties: { b: { $ref: '#/components/schemas/B' } } },
B: { type: 'object', properties: { a: { $ref: '#/components/schemas/A' } } },
})
// Both carry parameterized annotations referencing their model types (no bare z.ZodType =)
expect(out).not.toMatch(/: z\.ZodType =/)
expect(out).toContain('ASchema: z.ZodType<A>')
expect(out).toContain('BSchema: z.ZodType<B>')
// Both use assertion form (as z.ZodType<T>) so index-signature check is bypassed
expect(out).toContain('as z.ZodType<A>')
expect(out).toContain('as z.ZodType<B>')
expect(out).not.toContain('ASchema: z.ZodType<A>')
expect(out).not.toContain('BSchema: z.ZodType<B>')
// Both model types are imported type-only from models.ts
const importLine = out.match(/import type \{([^}]*)\} from '\.\/models\.js'/)
expect(importLine).not.toBeNull()
Expand All @@ -430,7 +433,7 @@ describe('circular / self-referential schemas', () => {
expect(out).not.toContain('interface _')
})

it('cyclic schema referencing an acyclic schema annotates only the recursive one', () => {
it('cyclic schema uses assertion form; acyclic schema stays plain', () => {
const out = gen({
Meta: { type: 'object', properties: { label: { type: 'string' } } },
Node: {
Expand All @@ -441,17 +444,55 @@ describe('circular / self-referential schemas', () => {
},
},
})
// Only the recursive Node is annotated + imported; the acyclic Meta stays plain.
// Only the recursive Node gets the assertion form + type-only import.
// The Node interface in models.ts references Meta (both in scope there); see
// generator-schema.test.ts for the models.ts side.
expect(out).toContain('NodeSchema: z.ZodType<Node>')
expect(out).toContain('as z.ZodType<Node>')
expect(out).not.toContain('NodeSchema: z.ZodType<Node>')
expect(out).toMatch(/import type \{ Node \} from '\.\/models\.js'/)
expect(out).not.toMatch(/MetaSchema:\s*z\.ZodType/)
expect(out).not.toMatch(/MetaSchema.*z\.ZodType/)
expect(out.match(/export const MetaSchema[^=]*=.*/)?.[0]).not.toContain('z.lazy(')
// No local helper interfaces are emitted into schemas.ts
expect(out).not.toContain('interface _')
})

it('recursive-through-array schema uses assertion form (array of $ref)', () => {
// Regression: all-optional mutual cycle via array of $ref (Author.books: Book[])
// plus oneOf with null (Book.author: Author | null). This shape triggered TS2322 with
// the annotation form on older TS/Zod versions because .passthrough() adds an index
// signature ({ [x: string]: unknown }) that could not be proved assignable to the strict
// model interface under those toolchains. The assertion form bypasses that check.
const out = gen({
Author: {
type: 'object',
properties: {
id: { type: 'string' },
books: { type: 'array', items: { $ref: '#/components/schemas/Book' } },
},
},
Book: {
type: 'object',
properties: {
id: { type: 'string' },
author: { oneOf: [{ $ref: '#/components/schemas/Author' }, { type: 'null' }] },
},
},
})
// Both must use assertion form, not annotation form
expect(out).toContain('as z.ZodType<Author>')
expect(out).toContain('as z.ZodType<Book>')
expect(out).not.toContain('AuthorSchema: z.ZodType<Author>')
expect(out).not.toContain('BookSchema: z.ZodType<Book>')
// Both are wrapped in z.lazy() for deferred resolution
expect(out).toContain('AuthorSchema = z.lazy(')
expect(out).toContain('BookSchema = z.lazy(')
// Both model types imported type-only from models.ts
const importLine = out.match(/import type \{([^}]*)\} from '\.\/models\.js'/)
expect(importLine).not.toBeNull()
expect(importLine![1]).toContain('Author')
expect(importLine![1]).toContain('Book')
})

it('non-circular schema is NOT wrapped in z.lazy()', () => {
const out = gen({
Tag: { type: 'object', properties: { id: { type: 'string' } } },
Expand All @@ -460,13 +501,14 @@ describe('circular / self-referential schemas', () => {
expect(out).not.toContain('z.lazy(')
})

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)

const out = gen({
Tag: { type: 'object', properties: { id: { type: 'string' } } },
Task: { type: 'object', properties: { tag: { $ref: '#/components/schemas/Tag' } } },
})
// Acyclic schemas remain as plain assignments with no type annotation
// Acyclic schemas remain as plain assignments with no type annotation or assertion
expect(out).not.toContain(': z.ZodType')
expect(out).not.toContain('as z.ZodType')
})
})

Expand Down
26 changes: 17 additions & 9 deletions packages/openapi-zod-ts/src/plugins/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,11 +488,18 @@

if (modelTypeName !== undefined) {
// Recursive (cyclic or self-referential) schema: wrap in z.lazy() so the deferred
// reference resolves after all schema constants are declared, and annotate with the
// concrete model type (a plain interface emitted in models.ts) so that
// z.infer<typeof FooSchema> resolves to that shape rather than unknown. The model type
// is imported type-only at the top of the file, which is erased at runtime (no cycle).
return `export const ${safeName}Schema: z.ZodType<${modelTypeName}> = z.lazy(() => ${schemaToZod(schema)})`
// reference resolves after all schema constants are declared, and cast with
// `as z.ZodType<ModelType>` so that z.infer<typeof FooSchema> resolves to the concrete
// model shape rather than unknown. The model type is imported type-only at the top of
// the file and erased at runtime (no cycle).
//
// We use an assertion (`as`) rather than an annotation (`: z.ZodType<T> = ...`) because
// the annotation form forces a strict assignability check that can fail for all-optional
// recursive schemas with .passthrough() under older TypeScript or Zod versions: the
// 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}>`

Check warning

Code scanning / CodeQL

Improper code sanitization Medium

Code construction depends on an
improperly sanitized value
.
Code construction depends on an
improperly sanitized value
.
Code construction depends on an
improperly sanitized value
.
Code construction depends on an
improperly sanitized value
.
Code construction depends on an
improperly sanitized value
.
}

return `export const ${safeName}Schema = ${schemaToZod(schema)}`
Expand Down Expand Up @@ -655,8 +662,9 @@

/**
* Emit the Zod constants for component schemas, topologically sorted so dependencies precede
* dependents. Recursive schemas are wrapped in z.lazy() and annotated z.ZodType<ModelType>,
* with those model types imported type-only from models.ts (erased at runtime, so no cycle).
* dependents. Recursive schemas are wrapped in z.lazy() and cast with `as z.ZodType<ModelType>`
* so that z.infer resolves to the concrete model rather than unknown. Those model types are
* imported type-only from models.ts and erased at runtime (no cycle).
*/
function emitComponentSchemas(
schemas: Record<string, SchemaObject | ReferenceObject>,
Expand Down Expand Up @@ -709,8 +717,8 @@
| Record<string, SchemaObject | ReferenceObject>
| undefined

// Recursive (cyclic or self-referential) schemas are annotated z.ZodType<ModelType>;
// their concrete model type lives in models.ts and is imported type-only.
// Recursive (cyclic or self-referential) schemas are cast with `as z.ZodType<ModelType>`
// so that z.infer resolves concretely. Their model type lives in models.ts, imported type-only.
const recursive = findRecursiveSchemaNames(spec)

const lines: string[] = [...SCHEMAS_FILE_HEADER, '', "import { z } from 'zod'"]
Expand Down
Loading