Skip to content

Commit 7a8b70f

Browse files
committed
Use const arrays for OpenAPI enum types
Preserve capitalization of named, exported enum values. Type exported const values as const, instead of their location within the operations or components schemas. Derive and export types for enum values from concrete values in const arrays. Use derived enum value types in operations and components schemas. Use non-conflicting variable names for composed OpenAPI enums (anyOf: [enum1, enum2])
1 parent 713ea1b commit 7a8b70f

File tree

14 files changed

+538
-260
lines changed

14 files changed

+538
-260
lines changed

.changeset/shaggy-impalas-develop.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript": major
3+
---
4+
5+
Export enumValues as const arrays. Derive schema types from literal values.

packages/openapi-typescript/examples/digital-ocean-api.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* Do not make direct changes to the file.
44
*/
55

6+
type WithRequired<T, K extends keyof T> = T & {
7+
[P in K]-?: T[P];
8+
};
69
export interface paths {
710
"/v2/1-clicks": {
811
parameters: {
@@ -27882,6 +27885,3 @@ export interface operations {
2788227885
};
2788327886
};
2788427887
}
27885-
type WithRequired<T, K extends keyof T> = T & {
27886-
[P in K]-?: T[P];
27887-
};

packages/openapi-typescript/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export default async function openapiTS(
8181
immutable: options.immutable ?? false,
8282
rootTypes: options.rootTypes ?? false,
8383
rootTypesNoSchemaPrefix: options.rootTypesNoSchemaPrefix ?? false,
84-
injectFooter: [],
84+
injectNodes: [],
8585
pathParamsAsTypes: options.pathParamsAsTypes ?? false,
8686
postTransform: typeof options.postTransform === "function" ? options.postTransform : undefined,
8787
propertiesRequiredByDefault: options.propertiesRequiredByDefault ?? false,

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

+101-58
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,14 @@ export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] {
209209
}
210210

211211
export const enumCache = new Map<string, ts.EnumDeclaration>();
212+
export const constEnumCache = new Map<string, ts.VariableStatement>();
213+
export type EnumMemberMetadata = { readonly name?: string; readonly description?: string };
212214

213215
/** Create a TS enum (with sanitized name and members) */
214216
export function tsEnum(
215217
name: string,
216218
members: (string | number)[],
217-
metadata?: { name?: string; description?: string }[],
219+
metadata?: EnumMemberMetadata[],
218220
options?: { export?: boolean; shouldCache?: boolean },
219221
) {
220222
let enumName = sanitizeMemberName(name);
@@ -224,69 +226,87 @@ export function tsEnum(
224226
key = `${members
225227
.slice(0)
226228
.sort()
227-
.map((v, i) => {
228-
return `${metadata?.[i]?.name ?? String(v)}:${metadata?.[i]?.description || ""}`;
229+
.map((v, index) => {
230+
return `${metadata?.[index]?.name ?? String(v)}:${metadata?.[index]?.description || ""}`;
229231
})
230232
.join(",")}`;
231-
if (enumCache.has(key)) {
232-
return enumCache.get(key) as ts.EnumDeclaration;
233+
234+
const cached = enumCache.get(key);
235+
if (cached) {
236+
return cached;
233237
}
234238
}
235239
const enumDeclaration = ts.factory.createEnumDeclaration(
236240
/* modifiers */ options ? tsModifiers({ export: options.export ?? false }) : undefined,
237241
/* name */ enumName,
238-
/* members */ members.map((value, i) => tsEnumMember(value, metadata?.[i])),
242+
/* members */ members.map((value, index) => tsEnumMember(value, metadata?.[index])),
239243
);
240244
options?.shouldCache && enumCache.set(key, enumDeclaration);
241245
return enumDeclaration;
242246
}
243247

244-
/** Create an exported TS array literal expression */
248+
/** Create a TS array literal expression */
245249
export function tsArrayLiteralExpression(
246250
name: string,
247-
elementType: ts.TypeNode,
248-
values: (string | number)[],
249-
options?: { export?: boolean; readonly?: boolean; injectFooter?: ts.Node[] },
251+
elementType: ts.TypeNode | undefined,
252+
members: (string | number)[],
253+
metadata?: readonly EnumMemberMetadata[],
254+
options?: { export?: boolean; readonly?: boolean; inject?: ts.Node[]; shouldCache?: boolean },
250255
) {
251-
let variableName = sanitizeMemberName(name);
252-
variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`;
256+
let key = "";
257+
if (options?.shouldCache) {
258+
key = `${members
259+
.slice(0)
260+
.sort()
261+
.map((v, i) => {
262+
return `${metadata?.[i]?.name ?? String(v)}:${metadata?.[i]?.description || ""}`;
263+
})
264+
.join(",")}`;
265+
const cached = constEnumCache.get(key);
266+
if (cached) {
267+
return cached;
268+
}
269+
}
270+
271+
const variableName = sanitizeMemberName(name);
272+
273+
const arrayType =
274+
(elementType && options?.readonly ? tsReadonlyArray(elementType, options.inject) : undefined) ??
275+
(elementType && !options?.readonly ? ts.factory.createArrayTypeNode(elementType) : undefined);
276+
277+
const initializer = ts.factory.createArrayLiteralExpression(
278+
members.flatMap((value, index) => {
279+
return tsLiteralValue(value, metadata?.[index]) ?? [];
280+
}),
281+
);
253282

254-
const arrayType = options?.readonly
255-
? tsReadonlyArray(elementType, options.injectFooter)
256-
: ts.factory.createArrayTypeNode(elementType);
283+
const asConstInitializer = arrayType
284+
? initializer
285+
: ts.factory.createAsExpression(initializer, ts.factory.createTypeReferenceNode("const"));
257286

258-
return ts.factory.createVariableStatement(
287+
const variableStatement = ts.factory.createVariableStatement(
259288
options ? tsModifiers({ export: options.export ?? false }) : undefined,
260289
ts.factory.createVariableDeclarationList(
261290
[
262291
ts.factory.createVariableDeclaration(
263-
variableName,
264-
undefined,
265-
arrayType,
266-
ts.factory.createArrayLiteralExpression(
267-
values.map((value) => {
268-
if (typeof value === "number") {
269-
if (value < 0) {
270-
return ts.factory.createPrefixUnaryExpression(
271-
ts.SyntaxKind.MinusToken,
272-
ts.factory.createNumericLiteral(Math.abs(value)),
273-
);
274-
} else {
275-
return ts.factory.createNumericLiteral(value);
276-
}
277-
} else {
278-
return ts.factory.createStringLiteral(value);
279-
}
280-
}),
281-
),
292+
/* name */ variableName,
293+
/* exclamationToken */ undefined,
294+
/* type */ arrayType,
295+
/* initializer */ asConstInitializer,
282296
),
283297
],
284298
ts.NodeFlags.Const,
285299
),
286300
);
301+
302+
if (options?.shouldCache) {
303+
constEnumCache.set(key, variableStatement);
304+
}
305+
306+
return variableStatement;
287307
}
288308

289-
function sanitizeMemberName(name: string) {
309+
export function sanitizeMemberName(name: string) {
290310
let sanitizedName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => {
291311
const last = c[c.length - 1];
292312
return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last) ? "" : last.toUpperCase();
@@ -298,7 +318,7 @@ function sanitizeMemberName(name: string) {
298318
}
299319

300320
/** Sanitize TS enum member expression */
301-
export function tsEnumMember(value: string | number, metadata: { name?: string; description?: string } = {}) {
321+
export function tsEnumMember(value: string | number, metadata: EnumMemberMetadata = {}) {
302322
let name = metadata.name ?? String(value);
303323
if (!JS_PROPERTY_INDEX_RE.test(name)) {
304324
if (Number(name[0]) >= 0) {
@@ -418,19 +438,40 @@ export function tsLiteral(value: unknown): ts.TypeNode {
418438
return UNKNOWN;
419439
}
420440

441+
/**
442+
* Create a literal value (different from a literal type), such as a string or number
443+
*/
444+
export function tsLiteralValue(value: string | number, metadata?: EnumMemberMetadata) {
445+
const literalExpression =
446+
(typeof value === "number" && value < 0
447+
? ts.factory.createPrefixUnaryExpression(
448+
ts.SyntaxKind.MinusToken,
449+
ts.factory.createNumericLiteral(Math.abs(value)),
450+
)
451+
: undefined) ??
452+
(typeof value === "number" ? ts.factory.createNumericLiteral(value) : undefined) ??
453+
(typeof value === "string" ? ts.factory.createStringLiteral(value) : undefined);
454+
455+
if (literalExpression && metadata?.description) {
456+
return ts.addSyntheticLeadingComment(
457+
literalExpression,
458+
ts.SyntaxKind.SingleLineCommentTrivia,
459+
" ".concat(metadata.description.trim()),
460+
);
461+
}
462+
463+
return literalExpression;
464+
}
465+
421466
/** Modifiers (readonly) */
422467
export function tsModifiers(modifiers: {
423468
readonly?: boolean;
424469
export?: boolean;
425470
}): ts.Modifier[] {
426-
const typeMods: ts.Modifier[] = [];
427-
if (modifiers.export) {
428-
typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ExportKeyword));
429-
}
430-
if (modifiers.readonly) {
431-
typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword));
432-
}
433-
return typeMods;
471+
return [
472+
modifiers.export ? ts.factory.createModifier(ts.SyntaxKind.ExportKeyword) : undefined,
473+
modifiers.readonly ? ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword) : undefined,
474+
].filter((modifier) => modifier !== undefined);
434475
}
435476

436477
/** Create a T | null union */
@@ -475,20 +516,23 @@ export function tsUnion(types: ts.TypeNode[]): ts.TypeNode {
475516
return ts.factory.createUnionTypeNode(tsDedupe(types));
476517
}
477518

519+
const withRequiredHelper: ts.Node = stringToAST(
520+
"type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };",
521+
)[0] as ts.Node;
522+
478523
/** Create a WithRequired<X, Y> type */
479524
export function tsWithRequired(
480525
type: ts.TypeNode,
481526
keys: string[],
482-
injectFooter: ts.Node[], // needed to inject type helper if used
527+
inject: ts.Node[], // needed to inject type helper if used
483528
): ts.TypeNode {
484529
if (keys.length === 0) {
485530
return type;
486531
}
487532

488533
// inject helper, if needed
489-
if (!injectFooter.some((node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "WithRequired")) {
490-
const helper = stringToAST("type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };")[0] as any;
491-
injectFooter.push(helper);
534+
if (!inject.includes(withRequiredHelper)) {
535+
inject.push(withRequiredHelper);
492536
}
493537

494538
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("WithRequired"), [
@@ -497,20 +541,19 @@ export function tsWithRequired(
497541
]);
498542
}
499543

544+
const readonlyArrayHelper: ts.Node = stringToAST(
545+
"type ReadonlyArray<T> = [Exclude<T, undefined>] extends [any[]] ? Readonly<Exclude<T, undefined>> : Readonly<Exclude<T, undefined>[]>;",
546+
)[0] as ts.Node;
547+
500548
/**
501549
* Enhanced ReadonlyArray.
502550
* eg: type Foo = ReadonlyArray<T>; type Bar = ReadonlyArray<T[]>
503551
* Foo and Bar are both of type `readonly T[]`
504552
*/
505-
export function tsReadonlyArray(type: ts.TypeNode, injectFooter?: ts.Node[]): ts.TypeNode {
506-
if (
507-
injectFooter &&
508-
!injectFooter.some((node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "ReadonlyArray")
509-
) {
510-
const helper = stringToAST(
511-
"type ReadonlyArray<T> = [Exclude<T, undefined>] extends [any[]] ? Readonly<Exclude<T, undefined>> : Readonly<Exclude<T, undefined>[]>;",
512-
)[0] as any;
513-
injectFooter.push(helper);
553+
export function tsReadonlyArray(type: ts.TypeNode, inject?: ts.Node[]): ts.TypeNode {
554+
if (inject && !inject.includes(readonlyArrayHelper)) {
555+
inject.push(readonlyArrayHelper);
514556
}
557+
515558
return ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("ReadonlyArray"), [type]);
516559
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import ts, { type InterfaceDeclaration, type TypeLiteralNode } from "typescript";
1+
import ts, { type TypeLiteralNode } from "typescript";
22
import { performance } from "node:perf_hooks";
33
import { NEVER, STRING, stringToAST, tsModifiers, tsRecord } from "../lib/ts.js";
44
import { createRef, debug } from "../lib/utils.js";
@@ -17,14 +17,16 @@ const transformers: Record<SchemaTransforms, (node: any, options: GlobalContext)
1717
$defs: (node, options) => transformSchemaObject(node, { path: createRef(["$defs"]), ctx: options }),
1818
};
1919

20-
export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
21-
const type: ts.Node[] = [];
20+
function isOperationsInterfaceNode(node: ts.Node) {
21+
const interfaceDeclaration = ts.isInterfaceDeclaration(node) ? node : undefined;
22+
return interfaceDeclaration?.name.escapedText === "operations";
23+
}
2224

23-
if (ctx.inject) {
24-
const injectNodes = stringToAST(ctx.inject) as ts.Node[];
25-
type.push(...injectNodes);
26-
}
25+
export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
26+
const schemaNodes: ts.Node[] = [];
2727

28+
// Traverse the schema root elements, gathering type information and accumulating
29+
// supporting nodes in `cts.injectNodes`.
2830
for (const root of Object.keys(transformers) as SchemaTransforms[]) {
2931
const emptyObj = ts.factory.createTypeAliasDeclaration(
3032
/* modifiers */ tsModifiers({ export: true }),
@@ -39,7 +41,7 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
3941
for (const subType of subTypes) {
4042
if (ts.isTypeNode(subType)) {
4143
if ((subType as ts.TypeLiteralNode).members?.length) {
42-
type.push(
44+
schemaNodes.push(
4345
ctx.exportType
4446
? ts.factory.createTypeAliasDeclaration(
4547
/* modifiers */ tsModifiers({ export: true }),
@@ -57,41 +59,42 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
5759
);
5860
debug(`${root} done`, "ts", performance.now() - rootT);
5961
} else {
60-
type.push(emptyObj);
62+
schemaNodes.push(emptyObj);
6163
debug(`${root} done (skipped)`, "ts", 0);
6264
}
6365
} else if (ts.isTypeAliasDeclaration(subType)) {
64-
type.push(subType);
66+
schemaNodes.push(subType);
6567
} else {
66-
type.push(emptyObj);
68+
schemaNodes.push(emptyObj);
6769
debug(`${root} done (skipped)`, "ts", 0);
6870
}
6971
}
7072
} else {
71-
type.push(emptyObj);
73+
schemaNodes.push(emptyObj);
7274
debug(`${root} done (skipped)`, "ts", 0);
7375
}
7476
}
7577

76-
// inject
77-
let hasOperations = false;
78-
for (const injectedType of ctx.injectFooter) {
79-
if (!hasOperations && (injectedType as InterfaceDeclaration)?.name?.escapedText === "operations") {
80-
hasOperations = true;
81-
}
82-
type.push(injectedType);
83-
}
84-
if (!hasOperations) {
85-
// if no operations created, inject empty operations type
86-
type.push(
87-
ts.factory.createTypeAliasDeclaration(
78+
// Identify any operations node that was injected during traversal
79+
const operationsNodeIndex = ctx.injectNodes.findIndex(isOperationsInterfaceNode);
80+
const hasOperations = operationsNodeIndex !== -1;
81+
const operationsNode = hasOperations
82+
? ctx.injectNodes.at(operationsNodeIndex)
83+
: ts.factory.createTypeAliasDeclaration(
8884
/* modifiers */ tsModifiers({ export: true }),
8985
/* name */ "operations",
9086
/* typeParameters */ undefined,
9187
/* type */ tsRecord(STRING, NEVER),
92-
),
93-
);
94-
}
88+
);
9589

96-
return type;
90+
return [
91+
// Inject user-defined header
92+
...(ctx.inject ? (stringToAST(ctx.inject) as ts.Node[]) : []),
93+
// Inject gathered values and types, except operations
94+
...ctx.injectNodes.filter((node) => node !== operationsNode),
95+
// Inject schema
96+
...schemaNodes,
97+
// Inject operations
98+
...(operationsNode ? [operationsNode] : []),
99+
];
97100
}

0 commit comments

Comments
 (0)