@@ -94,112 +94,119 @@ export function transformSchemaObjectWithComposition(
9494 if (
9595 Array . isArray ( schemaObject . enum ) &&
9696 ( ! ( "type" in schemaObject ) || schemaObject . type !== "object" ) &&
97- ! ( "properties" in schemaObject ) &&
98- ! ( "additionalProperties" in schemaObject )
97+ ! ( "properties" in schemaObject )
9998 ) {
100- // hoist enum to top level if string/number enum and option is enabled
101- if ( shouldTransformToTsEnum ( options , schemaObject ) ) {
102- let enumName = parseRef ( options . path ?? "" ) . pointer . join ( "/" ) ;
103- // allow #/components/schemas to have simpler names
104- enumName = enumName . replace ( "components/schemas" , "" ) ;
105- const metadata = schemaObject . enum . map ( ( _ , i ) => ( {
106- name : schemaObject [ "x-enum-varnames" ] ?. [ i ] ?? schemaObject [ "x-enumNames" ] ?. [ i ] ,
107- description : schemaObject [ "x-enum-descriptions" ] ?. [ i ] ?? schemaObject [ "x-enumDescriptions" ] ?. [ i ] ,
108- } ) ) ;
109-
110- // enums can contain null values, but dont want to output them
111- let hasNull = false ;
112- const validSchemaEnums = schemaObject . enum . filter ( ( enumValue ) => {
113- if ( enumValue === null ) {
114- hasNull = true ;
115- return false ;
99+ const hasAdditionalProperties = "additionalProperties" in schemaObject && ! ! schemaObject . additionalProperties ;
100+
101+ if ( ! hasAdditionalProperties || ( schemaObject . type === "string" && hasAdditionalProperties ) ) {
102+ // hoist enum to top level if string/number enum and option is enabled
103+ if ( shouldTransformToTsEnum ( options , schemaObject ) ) {
104+ let enumName = parseRef ( options . path ?? "" ) . pointer . join ( "/" ) ;
105+ // allow #/components/schemas to have simpler names
106+ enumName = enumName . replace ( "components/schemas" , "" ) ;
107+ const metadata = schemaObject . enum . map ( ( _ , i ) => ( {
108+ name : schemaObject [ "x-enum-varnames" ] ?. [ i ] ?? schemaObject [ "x-enumNames" ] ?. [ i ] ,
109+ description : schemaObject [ "x-enum-descriptions" ] ?. [ i ] ?? schemaObject [ "x-enumDescriptions" ] ?. [ i ] ,
110+ } ) ) ;
111+
112+ // enums can contain null values, but dont want to output them
113+ let hasNull = false ;
114+ const validSchemaEnums = schemaObject . enum . filter ( ( enumValue ) => {
115+ if ( enumValue === null ) {
116+ hasNull = true ;
117+ return false ;
118+ }
119+
120+ return true ;
121+ } ) ;
122+ const enumType = tsEnum ( enumName , validSchemaEnums as ( string | number ) [ ] , metadata , {
123+ shouldCache : options . ctx . dedupeEnums ,
124+ export : true ,
125+ // readonly: TS enum do not support the readonly modifier
126+ } ) ;
127+ if ( ! options . ctx . injectFooter . includes ( enumType ) ) {
128+ options . ctx . injectFooter . push ( enumType ) ;
116129 }
130+ const ref = ts . factory . createTypeReferenceNode ( enumType . name ) ;
117131
118- return true ;
119- } ) ;
120- const enumType = tsEnum ( enumName , validSchemaEnums as ( string | number ) [ ] , metadata , {
121- shouldCache : options . ctx . dedupeEnums ,
122- export : true ,
123- // readonly: TS enum do not support the readonly modifier
124- } ) ;
125- if ( ! options . ctx . injectFooter . includes ( enumType ) ) {
126- options . ctx . injectFooter . push ( enumType ) ;
132+ const finalType : ts . TypeNode = hasNull ? tsUnion ( [ ref , NULL ] ) : ref ;
133+
134+ return applyAdditionalPropertiesToEnum ( hasAdditionalProperties , finalType , schemaObject ) ;
135+ }
136+
137+ const enumType = schemaObject . enum . map ( tsLiteral ) ;
138+ if ( ( Array . isArray ( schemaObject . type ) && schemaObject . type . includes ( "null" ) ) || schemaObject . nullable ) {
139+ enumType . push ( NULL ) ;
127140 }
128- const ref = ts . factory . createTypeReferenceNode ( enumType . name ) ;
129- return hasNull ? tsUnion ( [ ref , NULL ] ) : ref ;
130- }
131- const enumType = schemaObject . enum . map ( tsLiteral ) ;
132- if ( ( Array . isArray ( schemaObject . type ) && schemaObject . type . includes ( "null" ) ) || schemaObject . nullable ) {
133- enumType . push ( NULL ) ;
134- }
135141
136- const unionType = tsUnion ( enumType ) ;
137-
138- // hoist array with valid enum values to top level if string/number enum and option is enabled
139- if ( options . ctx . enumValues && schemaObject . enum . every ( ( v ) => typeof v === "string" || typeof v === "number" ) ) {
140- const parsed = parseRef ( options . path ?? "" ) ;
141- let enumValuesVariableName = parsed . pointer . join ( "/" ) ;
142- // allow #/components/schemas to have simpler names
143- enumValuesVariableName = enumValuesVariableName . replace ( "components/schemas" , "" ) ;
144- enumValuesVariableName = `${ enumValuesVariableName } Values` ;
145-
146- // build a ref path for the type that ignores union indices (anyOf/oneOf) so
147- // type references remain stable even when names include union positions
148- const cleanedPointer : string [ ] = [ ] ;
149- // Track ALL properties after a oneOf/anyOf that need Extract<> narrowing.
150- // We apply Extract<> before EVERY property access after a union index because:
151- // - When the property exists on ALL variants, Extract<> is a no-op (returns same type)
152- // - When the property only exists on SOME variants, it correctly narrows the union
153- // - When both variants have same property name but different inner schemas,
154- // we still narrow at each level to handle nested unions correctly
155- // This robust approach handles both simple and complex union structures.
156- const extractProperties : string [ ] = [ ] ;
157- for ( let i = 0 ; i < parsed . pointer . length ; i ++ ) {
158- // Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
159- const segment = parsed . pointer [ i ] ;
160- if ( ( segment === "anyOf" || segment === "oneOf" ) && i < parsed . pointer . length - 1 ) {
161- const next = parsed . pointer [ i + 1 ] ;
162- if ( / ^ \d + $ / . test ( next ) ) {
163- // If we encounter something like "anyOf/0", we want to skip that part of the path
164- i ++ ;
165- // Collect ALL remaining segments after the union index.
166- // Each one will be wrapped with Extract<> to safely narrow the type
167- // at each level, handling both top-level and nested union variants.
168- const remainingSegments = parsed . pointer . slice ( i + 1 ) ;
169- for ( const seg of remainingSegments ) {
170- // Skip union keywords and indices, only add actual property names
171- if ( seg !== "anyOf" && seg !== "oneOf" && ! / ^ \d + $ / . test ( seg ) ) {
172- extractProperties . push ( seg ) ;
142+ const unionType = applyAdditionalPropertiesToEnum ( hasAdditionalProperties , tsUnion ( enumType ) , schemaObject ) ;
143+
144+ // hoist array with valid enum values to top level if string/number enum and option is enabled
145+ if ( options . ctx . enumValues && schemaObject . enum . every ( ( v ) => typeof v === "string" || typeof v === "number" ) ) {
146+ const parsed = parseRef ( options . path ?? "" ) ;
147+ let enumValuesVariableName = parsed . pointer . join ( "/" ) ;
148+ // allow #/components/schemas to have simpler names
149+ enumValuesVariableName = enumValuesVariableName . replace ( "components/schemas" , "" ) ;
150+ enumValuesVariableName = `${ enumValuesVariableName } Values` ;
151+
152+ // build a ref path for the type that ignores union indices (anyOf/oneOf) so
153+ // type references remain stable even when names include union positions
154+ const cleanedPointer : string [ ] = [ ] ;
155+ // Track ALL properties after a oneOf/anyOf that need Extract<> narrowing.
156+ // We apply Extract<> before EVERY property access after a union index because:
157+ // - When the property exists on ALL variants, Extract<> is a no-op (returns same type)
158+ // - When the property only exists on SOME variants, it correctly narrows the union
159+ // - When both variants have same property name but different inner schemas,
160+ // we still narrow at each level to handle nested unions correctly
161+ // This robust approach handles both simple and complex union structures.
162+ const extractProperties : string [ ] = [ ] ;
163+ for ( let i = 0 ; i < parsed . pointer . length ; i ++ ) {
164+ // Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
165+ const segment = parsed . pointer [ i ] ;
166+ if ( ( segment === "anyOf" || segment === "oneOf" ) && i < parsed . pointer . length - 1 ) {
167+ const next = parsed . pointer [ i + 1 ] ;
168+ if ( / ^ \d + $ / . test ( next ) ) {
169+ // If we encounter something like "anyOf/0", we want to skip that part of the path
170+ i ++ ;
171+ // Collect ALL remaining segments after the union index.
172+ // Each one will be wrapped with Extract<> to safely narrow the type
173+ // at each level, handling both top-level and nested union variants.
174+ const remainingSegments = parsed . pointer . slice ( i + 1 ) ;
175+ for ( const seg of remainingSegments ) {
176+ // Skip union keywords and indices, only add actual property names
177+ if ( seg !== "anyOf" && seg !== "oneOf" && ! / ^ \d + $ / . test ( seg ) ) {
178+ extractProperties . push ( seg ) ;
179+ }
173180 }
181+ continue ;
174182 }
175- continue ;
176183 }
184+ cleanedPointer . push ( segment ) ;
177185 }
178- cleanedPointer . push ( segment ) ;
186+ const cleanedRefPath = createRef ( cleanedPointer ) ;
187+
188+ const enumValuesArray = tsArrayLiteralExpression (
189+ enumValuesVariableName ,
190+ // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
191+ fromAdditionalProperties
192+ ? ts . factory . createIndexedAccessTypeNode (
193+ oapiRef ( cleanedRefPath , undefined , { deep : true , extractProperties } ) ,
194+ ts . factory . createTypeReferenceNode ( ts . factory . createIdentifier ( "string" ) ) ,
195+ )
196+ : oapiRef ( cleanedRefPath , undefined , { deep : true , extractProperties } ) ,
197+ schemaObject . enum as ( string | number ) [ ] ,
198+ {
199+ export : true ,
200+ readonly : true ,
201+ injectFooter : options . ctx . injectFooter ,
202+ } ,
203+ ) ;
204+
205+ options . ctx . injectFooter . push ( enumValuesArray ) ;
179206 }
180- const cleanedRefPath = createRef ( cleanedPointer ) ;
181-
182- const enumValuesArray = tsArrayLiteralExpression (
183- enumValuesVariableName ,
184- // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
185- fromAdditionalProperties
186- ? ts . factory . createIndexedAccessTypeNode (
187- oapiRef ( cleanedRefPath , undefined , { deep : true , extractProperties } ) ,
188- ts . factory . createTypeReferenceNode ( ts . factory . createIdentifier ( "string" ) ) ,
189- )
190- : oapiRef ( cleanedRefPath , undefined , { deep : true , extractProperties } ) ,
191- schemaObject . enum as ( string | number ) [ ] ,
192- {
193- export : true ,
194- readonly : true ,
195- injectFooter : options . ctx . injectFooter ,
196- } ,
197- ) ;
198207
199- options . ctx . injectFooter . push ( enumValuesArray ) ;
208+ return unionType ;
200209 }
201-
202- return unionType ;
203210 }
204211
205212 /**
@@ -525,7 +532,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
525532 ( "$defs" in schemaObject && schemaObject . $defs )
526533 ) {
527534 // properties
528- if ( Object . keys ( schemaObject . properties ?? { } ) . length ) {
535+ if ( "properties" in schemaObject && schemaObject . properties && Object . keys ( schemaObject ? .properties ) . length ) {
529536 for ( const [ k , v ] of getEntries ( schemaObject . properties ?? { } , options . ctx ) ) {
530537 if ( ( typeof v !== "object" && typeof v !== "boolean" ) || Array . isArray ( v ) ) {
531538 throw new Error (
@@ -609,7 +616,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
609616 }
610617
611618 // $defs
612- if ( schemaObject . $defs && typeof schemaObject . $defs === "object" && Object . keys ( schemaObject . $defs ) . length ) {
619+ if ( " $defs" in schemaObject && typeof schemaObject . $defs === "object" && Object . keys ( schemaObject . $defs ) . length ) {
613620 const defKeys : ts . TypeElement [ ] = [ ] ;
614621 for ( const [ k , v ] of Object . entries ( schemaObject . $defs ) ) {
615622 const defReadOnly = "readOnly" in v && ! ! v . readOnly ;
@@ -661,17 +668,21 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
661668 schemaObject . additionalProperties === true ||
662669 ( typeof schemaObject . additionalProperties === "object" &&
663670 Object . keys ( schemaObject . additionalProperties ) . length === 0 ) ;
671+ const patternProperties = hasKey ( schemaObject , "patternProperties" ) ? schemaObject . patternProperties : undefined ;
664672 const hasExplicitPatternProperties =
665- typeof schemaObject . patternProperties === "object" && Object . keys ( schemaObject . patternProperties ) . length ;
673+ typeof patternProperties === "object" && patternProperties !== null && Object . keys ( patternProperties ) . length > 0 ;
666674 const stringIndexTypes = [ ] ;
667675 if ( hasExplicitAdditionalProperties ) {
668676 stringIndexTypes . push ( transformSchemaObject ( schemaObject . additionalProperties as SchemaObject , options , true ) ) ;
669677 }
670678 if ( hasImplicitAdditionalProperties || ( ! schemaObject . additionalProperties && options . ctx . additionalProperties ) ) {
671679 stringIndexTypes . push ( UNKNOWN ) ;
672680 }
673- if ( hasExplicitPatternProperties ) {
674- for ( const [ _ , v ] of getEntries ( schemaObject . patternProperties ?? { } , options . ctx ) ) {
681+ if ( hasExplicitPatternProperties && patternProperties && typeof patternProperties === "object" ) {
682+ for ( const [ _ , v ] of getEntries (
683+ patternProperties as Record < string , SchemaObject | ReferenceObject > ,
684+ options . ctx ,
685+ ) ) {
675686 stringIndexTypes . push ( transformSchemaObject ( v , options ) ) ;
676687 }
677688 }
@@ -717,6 +728,19 @@ function hasKey<K extends string>(possibleObject: unknown, key: K): possibleObje
717728 return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject ;
718729}
719730
731+ function applyAdditionalPropertiesToEnum (
732+ hasAdditionalProperties : boolean ,
733+ unionType : ts . TypeNode ,
734+ schemaObject : SchemaObject ,
735+ ) {
736+ // If additionalProperties is true, add (string & {}) to the union
737+ if ( hasAdditionalProperties && schemaObject . type === "string" ) {
738+ const stringAndEmptyObject = tsIntersection ( [ STRING , ts . factory . createTypeLiteralNode ( [ ] ) ] ) ;
739+ return tsUnion ( [ unionType , stringAndEmptyObject ] ) ;
740+ }
741+ return unionType ;
742+ }
743+
720744/** Wrap type with $Read or $Write marker when readWriteMarkers flag is enabled */
721745function wrapWithReadWriteMarker (
722746 type : ts . TypeNode ,
0 commit comments