@@ -38,6 +38,10 @@ const context = {
3838 name : { type : 'string' } ,
3939 } ,
4040 } ,
41+ Status : {
42+ type : 'string' ,
43+ enum : [ 'new' , 'in_progress' ] ,
44+ } ,
4145 } ,
4246 } ,
4347 } ,
@@ -85,6 +89,185 @@ describe('combineSchemas (allOf required handling)', () => {
8589 expect ( result . value ) . toContain ( 'Required<Pick' ) ;
8690 } ) ;
8791
92+ // OAS 3.1's `{type: 'null'}` variant inside an anyOf/oneOf is the
93+ // nullable-enum spelling used by code generators like FastAPI. The result
94+ // should be flagged as an enum so the caller can extract a named type,
95+ // matching the equivalent `{type: ['string','null'], enum: [...]}` form.
96+ // See issue #2710.
97+ describe ( 'nullable enum composition (#2710)' , ( ) => {
98+ it ( 'flags anyOf [enum, null] as a nullable enum' , ( ) => {
99+ const schema : OpenApiSchemaObject = {
100+ anyOf : [ { enum : [ 'new' , 'in_progress' ] } , { type : 'null' } ] ,
101+ } ;
102+
103+ const result = combineSchemas ( {
104+ schema,
105+ name : 'Status' ,
106+ separator : 'anyOf' ,
107+ context,
108+ nullable : '' ,
109+ } ) ;
110+
111+ expect ( result . isEnum ) . toBe ( true ) ;
112+ expect ( result . value ) . toContain ( `'new' | 'in_progress'` ) ;
113+ expect ( result . value ) . toContain ( 'null' ) ;
114+ } ) ;
115+
116+ it ( 'flags oneOf [enum, null] as a nullable enum' , ( ) => {
117+ const schema : OpenApiSchemaObject = {
118+ oneOf : [ { enum : [ 'new' , 'in_progress' ] } , { type : 'null' } ] ,
119+ } ;
120+
121+ const result = combineSchemas ( {
122+ schema,
123+ name : 'Status' ,
124+ separator : 'oneOf' ,
125+ context,
126+ nullable : '' ,
127+ } ) ;
128+
129+ expect ( result . isEnum ) . toBe ( true ) ;
130+ expect ( result . value ) . toContain ( `'new' | 'in_progress'` ) ;
131+ expect ( result . value ) . toContain ( 'null' ) ;
132+ } ) ;
133+
134+ // Detection must not depend on the order of subschemas — `{type: 'null'}`
135+ // can appear before or after the enum.
136+ it ( 'flags anyOf [null, enum] (null first) as a nullable enum' , ( ) => {
137+ const schema : OpenApiSchemaObject = {
138+ anyOf : [ { type : 'null' } , { enum : [ 'new' , 'in_progress' ] } ] ,
139+ } ;
140+
141+ const result = combineSchemas ( {
142+ schema,
143+ name : 'Status' ,
144+ separator : 'anyOf' ,
145+ context,
146+ nullable : '' ,
147+ } ) ;
148+
149+ expect ( result . isEnum ) . toBe ( true ) ;
150+ } ) ;
151+
152+ // The pattern is type-agnostic: numeric enums combined with null should
153+ // also be recognized.
154+ it ( 'flags anyOf [numeric enum, null] as a nullable enum' , ( ) => {
155+ const schema : OpenApiSchemaObject = {
156+ anyOf : [ { type : 'integer' , enum : [ 1 , 2 , 3 ] } , { type : 'null' } ] ,
157+ } ;
158+
159+ const result = combineSchemas ( {
160+ schema,
161+ name : 'Code' ,
162+ separator : 'anyOf' ,
163+ context,
164+ nullable : '' ,
165+ } ) ;
166+
167+ expect ( result . isEnum ) . toBe ( true ) ;
168+ expect ( result . value ) . toContain ( '1 | 2 | 3' ) ;
169+ expect ( result . value ) . toContain ( 'null' ) ;
170+ } ) ;
171+
172+ // Pin behavior for the multi-enum + null variant. Each enum branch
173+ // contributes its values; the result is still a nullable enum union.
174+ it ( 'flags multiple inline enums + null as a nullable enum' , ( ) => {
175+ const schema : OpenApiSchemaObject = {
176+ anyOf : [ { enum : [ 'a' , 'b' ] } , { enum : [ 'c' , 'd' ] } , { type : 'null' } ] ,
177+ } ;
178+
179+ const result = combineSchemas ( {
180+ schema,
181+ name : 'Status' ,
182+ separator : 'anyOf' ,
183+ context,
184+ nullable : '' ,
185+ } ) ;
186+
187+ expect ( result . isEnum ) . toBe ( true ) ;
188+ expect ( result . value ) . toContain ( `'a' | 'b'` ) ;
189+ expect ( result . value ) . toContain ( `'c' | 'd'` ) ;
190+ expect ( result . value ) . toContain ( 'null' ) ;
191+ } ) ;
192+
193+ // Negative: a plain nullable string (no enum) must stay a generic union
194+ // and not be flagged as an enum. This is the case the existing
195+ // query-params.test.ts:169 test already pins at the integration level.
196+ it ( 'does not flag anyOf [non-enum scalar, null] as a nullable enum' , ( ) => {
197+ const schema : OpenApiSchemaObject = {
198+ anyOf : [ { type : 'string' , format : 'uuid' } , { type : 'null' } ] ,
199+ } ;
200+
201+ const result = combineSchemas ( {
202+ schema,
203+ name : 'AffiliationId' ,
204+ separator : 'anyOf' ,
205+ context,
206+ nullable : '' ,
207+ } ) ;
208+
209+ expect ( result . isEnum ) . toBe ( false ) ;
210+ } ) ;
211+
212+ // Negative: a `$ref` branch already resolves to an existing named enum
213+ // schema. Treating this composition as an inline-enum would route the
214+ // caller through `getEnum`, which emits a parallel const that nests the
215+ // original ref (e.g. `{Status: Status}`) instead of reusing it.
216+ it ( 'does not flag anyOf [$ref enum, null] as a nullable enum' , ( ) => {
217+ const schema : OpenApiSchemaObject = {
218+ anyOf : [ { $ref : '#/components/schemas/Status' } , { type : 'null' } ] ,
219+ } ;
220+
221+ const result = combineSchemas ( {
222+ schema,
223+ name : 'Status' ,
224+ separator : 'anyOf' ,
225+ context,
226+ nullable : '' ,
227+ } ) ;
228+
229+ expect ( result . isEnum ) . toBe ( false ) ;
230+ } ) ;
231+
232+ // Negative: `allOf` is an intersection, not a union. `allOf: [{enum}, {null}]`
233+ // is semantically empty (no value can satisfy both); regardless, it must
234+ // not be misclassified as a nullable enum union.
235+ it ( 'does not flag allOf [enum, null] as a nullable enum' , ( ) => {
236+ const schema : OpenApiSchemaObject = {
237+ allOf : [ { enum : [ 'new' , 'in_progress' ] } , { type : 'null' } ] ,
238+ } ;
239+
240+ const result = combineSchemas ( {
241+ schema,
242+ name : 'Status' ,
243+ separator : 'allOf' ,
244+ context,
245+ nullable : '' ,
246+ } ) ;
247+
248+ expect ( result . isEnum ) . toBe ( false ) ;
249+ } ) ;
250+
251+ // Negative: an enum combined with a non-null scalar is a genuine union,
252+ // not a nullable enum. Extracting it as a named enum would change the
253+ // semantics (the other branch's values would be lost).
254+ it ( 'does not flag anyOf [enum, non-null scalar] as a nullable enum' , ( ) => {
255+ const schema : OpenApiSchemaObject = {
256+ anyOf : [ { enum : [ 'new' , 'in_progress' ] } , { type : 'string' } ] ,
257+ } ;
258+
259+ const result = combineSchemas ( {
260+ schema,
261+ name : 'Status' ,
262+ separator : 'anyOf' ,
263+ context,
264+ nullable : '' ,
265+ } ) ;
266+
267+ expect ( result . isEnum ) . toBe ( false ) ;
268+ } ) ;
269+ } ) ;
270+
88271 it ( 'normalizes inline object in allOf to match parent object form' , ( ) => {
89272 const variantA : OpenApiSchemaObject = {
90273 allOf : [
0 commit comments