Skip to content

Commit 4dc03f3

Browse files
authored
fix(core): extract named enum type for anyOf nullable enum composition (#3424)
1 parent 526666c commit 4dc03f3

3 files changed

Lines changed: 399 additions & 2 deletions

File tree

packages/core/src/getters/combine.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: [

packages/core/src/getters/combine.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,32 @@ export function combineSchemas({
344344
}
345345

346346
const isAllEnums = resolvedData.isEnum.every(Boolean);
347+
// OAS 3.1 spells a nullable enum as `anyOf: [{enum: [...]}, {type: 'null'}]`.
348+
// Without this, the {type: 'null'} variant flips `isEnum` to false and the
349+
// enum gets inlined instead of extracted as a named type — the
350+
// {type: ['string','null'], enum: [...]} spelling already extracts. Treat
351+
// null-only variants as transparent so the caller's `isEnum && !isRef`
352+
// branch (query-params, schema-definition, resolvers/object) extracts via
353+
// getEnum, whose `stripNullUnion` handling already preserves the trailing
354+
// ` | null`. See issue #2710.
355+
//
356+
// Guards:
357+
// - `allOf` semantics are intersection, not union — `allOf: [{enum}, {null}]`
358+
// does not describe a nullable enum, so restrict to `anyOf`/`oneOf`.
359+
// - Non-null branches must be inline enums (`!isRef`). For `$ref + null`
360+
// the existing referenced enum should be reused; routing through
361+
// `getEnum` would emit a parallel const that nests the original ref
362+
// (e.g. `{Status: Status}`) instead of spreading or aliasing it.
363+
const isUnionLikeSeparator = separator === 'anyOf' || separator === 'oneOf';
364+
const isNullableEnumComposition =
365+
isUnionLikeSeparator &&
366+
!isAllEnums &&
367+
resolvedData.isEnum.some(Boolean) &&
368+
resolvedData.isEnum.every(
369+
(isEnum, index) =>
370+
(isEnum && !resolvedData.isRef[index]) ||
371+
resolvedData.types[index] === 'null',
372+
);
347373
const isAvailableToGenerateCombinedEnum =
348374
isAllEnums &&
349375
name &&
@@ -460,7 +486,7 @@ export function combineSchemas({
460486
dependencies: resolvedValue
461487
? [...resolvedData.dependencies, ...resolvedValue.dependencies]
462488
: resolvedData.dependencies,
463-
isEnum: false,
489+
isEnum: isNullableEnumComposition,
464490
type: 'object' as SchemaType,
465491
isRef: false,
466492
hasReadonlyProps:

0 commit comments

Comments
 (0)