Skip to content

Generate discriminated union types from OpenAPI discriminators #3270

@aldirrix

Description

@aldirrix

Description

Hello! Love this project and been using it for a while, I've come up to the following scenario and would maybe like your input on this and see if it makes sense.

Setting

OpenAPI discriminators are not generating narrowed TypeScript types or Zod discriminated unions which provides a not ideal developer experience

Minimal Reproducible Example

  {                                                         
    "openapi": "3.1.0",                                     
    "info": {                                               
      "title": "Example API",                               
      "version": "1.0.0"                                    
    },                                                      
    "servers": [                                            
      {                                                     
        "url": "https://api.example.com"                    
      }                                                     
    ],                                                      
    "paths": {},                                            
    "components": {                                         
      "schemas": {                                          
        "Event": {                                          
          "type": "object",                                 
          "additionalProperties": true,                     
          "discriminator": {                                
            "propertyName": "type",                         
            "mapping": {                                    
              "created": "#/components/schemas/CreatedEvent",                      
              "updated": "#/components/schemas/UpdatedEvent"
            }                                               
          },                                                
          "properties": {                                   
            "type": {                                       
              "type": "string"                              
            },                                              
            "timestamp": {                                  
              "type": "string",                             
              "format": "date-time"                         
            }                                               
          },                                                
          "required": ["type"]                              
        },                                                  
        "CreatedEvent": {                                   
          "additionalProperties": true,                     
          "allOf": [                                        
            {                                               
              "$ref": "#/components/schemas/Event"          
            },                                              
            {                                               
              "type": "object",                             
              "properties": {                               
                "resourceId": {                             
                  "type": "string"                          
                }                                           
              }                                             
            }                                               
          ],                                                
          "required": ["type", "resourceId"]                
        },                                                  
        "UpdatedEvent": {                                   
          "additionalProperties": true,                     
          "allOf": [                                        
            {                                               
              "$ref": "#/components/schemas/Event"          
            },                                              
            {                                               
              "type": "object",                             
              "properties": {                               
                "resourceId": {                             
                  "type": "string"                          
                },                                          
                "changes": {                                
                  "type": "object"                          
                }                                           
              }                                             
            }                                               
          ],                                                
          "required": ["type", "resourceId"]                
        }                                                   
      }                                                     
    }                                                       
  }                                                         

Current generation of types & schemas
types.gen.ts:

  export type Event = {                                     
      type: string;  // here's where the discriminator could use it
      timestamp?: string;                                   
      [key: string]: unknown | string | undefined;          
  };                                                        
                                                       
  export type CreatedEvent = Omit<Event, 'type'> & {        
      resourceId: string;                                   
      type: 'created';                                      
  };                                                        
                                                            
  export type UpdatedEvent = Omit<Event, 'type'> & {        
      resourceId: string;                                   
      changes?: Record<string, unknown>;                    
      type: 'updated';                                      
  };                                                        

zod.gen.ts

  export const zEvent = z.object({                          
      type: z.string(),  // just string 
  union                                                     
      timestamp: z.optional(z.iso.datetime())               
  });                                                       
                                                            
  export const zCreatedEvent = zEvent.and(z.object({        
      resourceId: z.string(),                               
      type: z.literal('created')                            
  }));                                                      
                                                            
  export const zUpdatedEvent = zEvent.and(z.object({        
      resourceId: z.string(),                               
      changes: z.optional(z.record(z.unknown())),           
      type: z.literal('updated')                            
  }));                                                      

Issues:

  1. Base Event type has type: string instead of narrowed
  2. union 'created' | 'updated'
  3. No union type is generated combining all variants
  4. Zod uses .and() merging instead of z.discriminatedUnion()
  5. Results in slower runtime validation and no automatic type narrowing

Desired Behavior

Types:

  export type Event =                                       
    | CreatedEvent                                          
    | UpdatedEvent;                                         
                                                                                                                 
  export type CreatedEvent = {                              
      type: 'created';                                      
      timestamp?: string;                                   
      resourceId: string;                                   
  };                                                        
                                                            
  export type UpdatedEvent = {                              
      type: 'updated';                                      
      timestamp?: string;                                   
      resourceId: string;                                   
      changes?: Record<string, unknown>;                    
  };                                                        

Zod:

  export const zCreatedEvent = z.object({             
    type: z.literal('created'),                             
    timestamp: z.optional(z.iso.datetime())   
    resourceId: z.string()                                  
  });                                                       
                                                            
  export const zUpdatedEvent = z.object({
    type: z.literal('updated'),
    timestamp: z.optional(z.iso.datetime())      
    resourceId: z.string(),                                 
    changes: z.optional(z.record(z.unknown()))              
  });                                                       
                                                            
  // Discriminated union                    
  export const zEvent = z.discriminatedUnion('type', [      
    zCreatedEvent,                                          
    zUpdatedEvent                                           
  ]);                                                       

Environment

  • @hey-api/openapi-ts: v0.90.10
  • OpenAPI version: 3.1.0
  • Plugins: @hey-api/typescript, zod

openapi-ts.ts

  import { defineConfig } from '@hey-api/openapi-ts';       
                                                            
  export default defineConfig({                             
    input: './openapi-spec.json',                           
    output: {                                               
      path: './src/generated',                              
    },                                                      
    plugins: [                                              
      {                                                     
        name: '@hey-api/typescript',                        
        style: 'PascalCase',                                
      },                                                    
      {                                                     
        name: 'zod',                                        
        exportFromIndex: true,                              
      },                                                    
    ],                                                      
  });   

Metadata

Metadata

Labels

Type

No type
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