Skip to content

Commit b33f83a

Browse files
committed
fix(enum-values): enumValues with nested union types
1 parent af90780 commit b33f83a

File tree

3 files changed

+407
-10
lines changed

3 files changed

+407
-10
lines changed

packages/openapi-typescript/src/lib/ts.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,8 @@ function isParameterObject(obj: OapiRefResolved | undefined): obj is ParameterOb
134134
return Boolean(obj && !isOasRef(obj) && obj.in);
135135
}
136136

137-
function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode, ...segments: readonly string[]) {
138-
return segments.reduce((acc, segment) => {
137+
function addIndexedAccess(node: ts.TypeNode, ...segments: readonly string[]) {
138+
return segments.reduce<ts.TypeNode>((acc, segment) => {
139139
return ts.factory.createIndexedAccessTypeNode(
140140
acc,
141141
ts.factory.createLiteralTypeNode(
@@ -147,6 +147,31 @@ function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode,
147147
}, node);
148148
}
149149

150+
/**
151+
* Wrap a type with Extract<T, { propertyName: unknown }> to narrow a union type
152+
* before accessing a property that only exists on some variants.
153+
*/
154+
function wrapWithExtract(type: ts.TypeNode, propertyName: string): ts.TypeNode {
155+
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("Extract"), [
156+
type,
157+
ts.factory.createTypeLiteralNode([
158+
ts.factory.createPropertySignature(
159+
/* modifiers */ undefined,
160+
/* name */ ts.factory.createIdentifier(propertyName),
161+
/* questionToken */ undefined,
162+
/* type */ ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword),
163+
),
164+
]),
165+
]);
166+
}
167+
168+
export interface OapiRefOptions {
169+
/** Whether to wrap with FlattenedDeepRequired<> (default: false) */
170+
deep?: boolean;
171+
/** Array of property names to wrap with Extract<> when accessing */
172+
extractProperties?: string[];
173+
}
174+
150175
/**
151176
* Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`)
152177
* `path` is a JSON Pointer to a location within an OpenAPI document.
@@ -163,14 +188,18 @@ function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode,
163188
* them according to their type; path, query, header, etc… so in these cases we
164189
* must check the parameter definition to determine the how to index into
165190
* the openapi-typescript type.
191+
* * Union variant properties (oneOf/anyOf)
192+
* When accessing properties that may only exist on some variants of a union type,
193+
* we use Extract<> to narrow the type before each property access.
166194
**/
167-
export function oapiRef(path: string, resolved?: OapiRefResolved, deep = false): ts.TypeNode {
195+
export function oapiRef(path: string, resolved?: OapiRefResolved, options: OapiRefOptions = {}): ts.TypeNode {
168196
const { pointer } = parseRef(path);
169197
if (pointer.length === 0) {
170198
throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`);
171199
}
172200

173201
const parametersObject = isParameterObject(resolved);
202+
const extractSet = new Set(options.extractProperties ?? []);
174203

175204
// Initial segments are handled in a fixed , then remaining segments are treated
176205
// according to heuristics based on the initial segments
@@ -180,12 +209,14 @@ export function oapiRef(path: string, resolved?: OapiRefResolved, deep = false):
180209

181210
const leadingType = addIndexedAccess(
182211
ts.factory.createTypeReferenceNode(
183-
ts.factory.createIdentifier(deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment)),
212+
ts.factory.createIdentifier(
213+
options.deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment),
214+
),
184215
),
185216
...leadingSegments,
186217
);
187218

188-
return restSegments.reduce<ts.TypeReferenceNode | ts.IndexedAccessTypeNode>((acc, segment, index, original) => {
219+
return restSegments.reduce<ts.TypeNode>((acc, segment, index, original) => {
189220
// Skip `properties` items when in the middle of the pointer
190221
// See: https://github.com/openapi-ts/openapi-typescript/issues/1742
191222
if (segment === "properties") {
@@ -196,6 +227,14 @@ export function oapiRef(path: string, resolved?: OapiRefResolved, deep = false):
196227
return addIndexedAccess(acc, resolved.in, resolved.name);
197228
}
198229

230+
// If this segment is in the extractProperties list,
231+
// wrap the current type with Extract<T, { segment: unknown }> before accessing.
232+
// This narrows union types to variants that have this property.
233+
if (extractSet.has(segment)) {
234+
const narrowedType = wrapWithExtract(acc, segment);
235+
return addIndexedAccess(narrowedType, segment);
236+
}
237+
199238
return addIndexedAccess(acc, segment);
200239
}, leadingType);
201240
}

packages/openapi-typescript/src/transform/schema-object.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ export function transformSchemaObjectWithComposition(
149149
// build a ref path for the type that ignores union indices (anyOf/oneOf) so
150150
// type references remain stable even when names include union positions
151151
const cleanedPointer: string[] = [];
152+
// Track ALL properties after a oneOf/anyOf that need Extract<> narrowing.
153+
// We apply Extract<> before EVERY property access after a union index because:
154+
// - When the property exists on ALL variants, Extract<> is a no-op (returns same type)
155+
// - When the property only exists on SOME variants, it correctly narrows the union
156+
// - When both variants have same property name but different inner schemas,
157+
// we still narrow at each level to handle nested unions correctly
158+
// This robust approach handles both simple and complex union structures.
159+
const extractProperties: string[] = [];
152160
for (let i = 0; i < parsed.pointer.length; i++) {
153161
// Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
154162
const segment = parsed.pointer[i];
@@ -157,6 +165,16 @@ export function transformSchemaObjectWithComposition(
157165
if (/^\d+$/.test(next)) {
158166
// If we encounter something like "anyOf/0", we want to skip that part of the path
159167
i++;
168+
// Collect ALL remaining segments after the union index.
169+
// Each one will be wrapped with Extract<> to safely narrow the type
170+
// at each level, handling both top-level and nested union variants.
171+
const remainingSegments = parsed.pointer.slice(i + 1);
172+
for (const seg of remainingSegments) {
173+
// Skip union keywords and indices, only add actual property names
174+
if (seg !== "anyOf" && seg !== "oneOf" && !/^\d+$/.test(seg)) {
175+
extractProperties.push(seg);
176+
}
177+
}
160178
continue;
161179
}
162180
}
@@ -169,10 +187,10 @@ export function transformSchemaObjectWithComposition(
169187
// If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
170188
fromAdditionalProperties
171189
? ts.factory.createIndexedAccessTypeNode(
172-
oapiRef(cleanedRefPath, undefined, true),
190+
oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }),
173191
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
174192
)
175-
: oapiRef(cleanedRefPath, undefined, true),
193+
: oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }),
176194
schemaObject.enum as (string | number)[],
177195
{
178196
export: true,

0 commit comments

Comments
 (0)