diff --git a/examples/generated/canada_holidays/schemas.ts b/examples/generated/canada_holidays/schemas.ts index e86268dd..16892dc4 100644 --- a/examples/generated/canada_holidays/schemas.ts +++ b/examples/generated/canada_holidays/schemas.ts @@ -28,7 +28,7 @@ export const ErrorSchema = z.object({ timestamp: z.iso.datetime().optional() }).passthrough() -export const HolidaySchema: z.ZodType = 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), @@ -37,9 +37,9 @@ export const HolidaySchema: z.ZodType = 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 -export const ProvinceSchema: z.ZodType = 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(), @@ -48,7 +48,7 @@ export const ProvinceSchema: z.ZodType = z.lazy(() => z.object({ provinces: z.array(HolidaySchema).optional(), sourceEn: z.string(), sourceLink: z.string().regex(new RegExp("https+")) -}).passthrough()) +}).passthrough()) as z.ZodType // Synthesized schemas for inline JSON responses (operationId-based naming). // These are used by openapi-server to wire schema.response for Fastify routes. diff --git a/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts b/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts index ab1b0b86..2d825691 100644 --- a/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/generator-schema.test.ts @@ -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') - expect(schemas).toContain('ProvinceSchema: z.ZodType') + // Holiday and Province are mutually cyclic: both wrapped in z.lazy() and cast with + // the assertion form `as z.ZodType` so that z.infer resolves to the concrete model. + // The annotation form (: z.ZodType = ...) was fragile for all-optional recursive + // schemas with .passthrough() under older TS/Zod combinations. + expect(schemas).toContain('as z.ZodType') + expect(schemas).toContain('as z.ZodType') + expect(schemas).not.toContain('HolidaySchema: z.ZodType') + expect(schemas).not.toContain('ProvinceSchema: z.ZodType') 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') }) }) diff --git a/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts b/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts index 94b2c2d0..771c471f 100644 --- a/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts +++ b/packages/openapi-zod-ts/src/__tests__/zod-unit.test.ts @@ -381,7 +381,7 @@ describe('circular / self-referential schemas', () => { expect(out).toContain('TreeNodeSchema') }) - it('self-referential schema annotation uses z.ZodType, not bare z.ZodType', () => { + it('self-referential schema uses assertion form `as z.ZodType`, not annotation form', () => { const out = gen({ TreeNode: { type: 'object', @@ -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') - expect(out).not.toMatch(/: z\.ZodType =/) + // Uses assertion form (as z.ZodType) so passthrough index-signature is not checked + // against the strict model type. Annotation form (: z.ZodType = ...) was fragile for + // all-optional recursive schemas with .passthrough() under older TS/Zod combinations. + expect(out).toContain('as z.ZodType') + expect(out).not.toContain('TreeNodeSchema: z.ZodType') // 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 @@ -412,15 +414,16 @@ describe('circular / self-referential schemas', () => { expect(bDecl?.[0]).toContain('z.lazy(') }) - it('mutually circular schemas carry z.ZodType annotations, not bare z.ZodType', () => { + it('mutually circular schemas use assertion form `as z.ZodType`, 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') - expect(out).toContain('BSchema: z.ZodType') + // Both use assertion form (as z.ZodType) so index-signature check is bypassed + expect(out).toContain('as z.ZodType') + expect(out).toContain('as z.ZodType') + expect(out).not.toContain('ASchema: z.ZodType') + expect(out).not.toContain('BSchema: z.ZodType') // Both model types are imported type-only from models.ts const importLine = out.match(/import type \{([^}]*)\} from '\.\/models\.js'/) expect(importLine).not.toBeNull() @@ -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: { @@ -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') + expect(out).toContain('as z.ZodType') + expect(out).not.toContain('NodeSchema: z.ZodType') 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') + expect(out).toContain('as z.ZodType') + expect(out).not.toContain('AuthorSchema: z.ZodType') + expect(out).not.toContain('BookSchema: z.ZodType') + // 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' } } }, @@ -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)', () => { 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') }) }) diff --git a/packages/openapi-zod-ts/src/plugins/zod.ts b/packages/openapi-zod-ts/src/plugins/zod.ts index e35a09e0..109f1b8d 100644 --- a/packages/openapi-zod-ts/src/plugins/zod.ts +++ b/packages/openapi-zod-ts/src/plugins/zod.ts @@ -488,11 +488,18 @@ function generateSchemaDeclaration( 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 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` so that z.infer 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 = ...`) 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}>` } return `export const ${safeName}Schema = ${schemaToZod(schema)}` @@ -655,8 +662,9 @@ const SCHEMAS_FILE_HEADER: readonly string[] = [ /** * Emit the Zod constants for component schemas, topologically sorted so dependencies precede - * dependents. Recursive schemas are wrapped in z.lazy() and annotated z.ZodType, - * 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` + * 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, @@ -709,8 +717,8 @@ export function generateZodSchemas(spec: OpenAPIV3_1.Document): GeneratedFile { | Record | undefined - // Recursive (cyclic or self-referential) schemas are annotated z.ZodType; - // 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` + // 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'"]