Skip to content

Zod plugin: allOf-composed variants in a oneOf + discriminator degrade the whole union from z.discriminatedUnion to z.union #4241

Description

@aivanychev23

Description

Package/version: @hey-api/openapi-ts@0.98.2 · plugins: @hey-api/typescript, zod · zod 4

Summary

When a oneOf + discriminator schema has variants defined via allOf (a shared
base object + a per-variant object), the zod plugin renders each variant as
zBase.and(z.object({ … })) — a ZodIntersection. Because a ZodIntersection is
not a ZodObject, the plugin can't use it as a z.discriminatedUnion member, so it
falls back to z.union([...]) for the entire envelope. A single allOf variant
degrades all of them, including the ones written as flat objects.

This is the allOf case originally shown in #3270. That issue was closed via #3780,
but the shipped fix only covers variants that are directly object schemas; the
allOf-composed variant still regresses on 0.98.2. So "issue closed" doesn't cover
this path — hence a fresh report.

Minimal reproduction

Single-file spec:

openapi: 3.1.0
info: { title: repro, version: 1.0.0 }
paths: {}
components:
  schemas:
    Base:
      type: object
      required: [kind, id]
      properties:
        kind: { type: string }
        id: { type: string }
    Cat:
      allOf:
        - $ref: '#/components/schemas/Base'
        - type: object
          required: [meowVolume]
          properties:
            kind: { type: string, enum: [cat] }
            meowVolume: { type: integer }
    Dog:
      allOf:
        - $ref: '#/components/schemas/Base'
        - type: object
          required: [barkVolume]
          properties:
            kind: { type: string, enum: [dog] }
            barkVolume: { type: integer }
    Animal:
      oneOf:
        - $ref: '#/components/schemas/Cat'
        - $ref: '#/components/schemas/Dog'
      discriminator:
        propertyName: kind
        mapping:
          cat: '#/components/schemas/Cat'
          dog: '#/components/schemas/Dog'

Config: plugins: ['@hey-api/typescript', 'zod'].

Actual output (zod)

export const zCat = zBase.and(z.object({ kind: z.enum(['cat']), meowVolume: z.int() }));
export const zDog = zBase.and(z.object({ kind: z.enum(['dog']), barkVolume: z.int() }));
export const zAnimal = z.union([
  z.object({ kind: z.literal('cat') }).and(zCat),
  z.object({ kind: z.literal('dog') }).and(zDog),
]);

Expected output (zod)

Flatten the allOf object members into a single ZodObject before assembling the
union — i.e. the same output produced when the variants are written as flat objects:

export const zCat = z.object({ id: z.string(), kind: z.enum(['cat']), meowVolume: z.int() });
export const zDog = z.object({ id: z.string(), kind: z.enum(['dog']), barkVolume: z.int() });
export const zAnimal = z.discriminatedUnion('kind', [
  zCat.extend({ kind: z.literal('cat') }),
  zDog.extend({ kind: z.literal('dog') }),
]);

Impact

  • Loses z.discriminatedUnion narrowing and its targeted parse-error messages.
  • Breaks downstream code that walks schema.options[i].shape — union members are
    ZodIntersection, which has no .shape.
  • Forces authors to inline the shared envelope fields into every variant (no allOf
    reuse), which is painful at scale (hundreds of variants).

Reproducible example or configuration

No response

OpenAPI specification (optional)

No response

System information (optional)

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug 🔥Broken or incorrect behavior.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions