Skip to content

Commit b6d6fcb

Browse files
committed
Fix prefixItems / minItems / maxItems tuple generation (#2053)
* Simplify minItems / maxItems tuple generation Closes #2048
1 parent 82e98b4 commit b6d6fcb

File tree

11 files changed

+591
-95
lines changed

11 files changed

+591
-95
lines changed

.changeset/clean-phones-deliver.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"openapi-typescript": minor
3+
---
4+
5+
Extract types generation for Array-type schemas to `transformArraySchemaObject` method.
6+
Generate correct number of union members for `minItems` * `maxItems` unions.
7+
Generate readonly tuple members for `minItems` & `maxItems` unions.
8+
Generate readonly spread member for `prefixItems` tuple.
9+
Preserve `prefixItems` type members in `minItems` & `maxItems` tuples.
10+
Generate spread member for `prefixItems` tuple with no `minItems` / `maxItems` constraints.

packages/openapi-typescript/bin/cli.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ Options
3434
--root-types-no-schema-prefix (optional)
3535
Do not add "Schema" prefix to types at the root level (should only be used with --root-types)
3636
--make-paths-enum Generate ApiPaths enum for all paths
37+
38+
Experimental features
39+
--experimental-spread-array-members
40+
Array schemas with prefixItems spread remaining items
3741
`;
3842

3943
const OUTPUT_FILE = "FILE";
@@ -77,6 +81,7 @@ const flags = parser(args, {
7781
"dedupeEnums",
7882
"check",
7983
"excludeDeprecated",
84+
"experimentalArraySpreadMembers",
8085
"exportType",
8186
"help",
8287
"immutable",
@@ -133,7 +138,6 @@ async function generateSchema(schema, { redocly, silent = false }) {
133138
additionalProperties: flags.additionalProperties,
134139
alphabetize: flags.alphabetize,
135140
arrayLength: flags.arrayLength,
136-
contentNever: flags.contentNever,
137141
propertiesRequiredByDefault: flags.propertiesRequiredByDefault,
138142
defaultNonNullable: flags.defaultNonNullable,
139143
emptyObjectsUnknown: flags.emptyObjectsUnknown,
@@ -142,6 +146,7 @@ async function generateSchema(schema, { redocly, silent = false }) {
142146
dedupeEnums: flags.dedupeEnums,
143147
excludeDeprecated: flags.excludeDeprecated,
144148
exportType: flags.exportType,
149+
experimentalArraySpreadMembers: flags.experimentalArraySpreadMembers,
145150
immutable: flags.immutable,
146151
pathParamsAsTypes: flags.pathParamsAsTypes,
147152
rootTypes: flags.rootTypes,

packages/openapi-typescript/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"yargs-parser": "^21.1.1"
6969
},
7070
"devDependencies": {
71+
"@total-typescript/ts-reset": "^0.6.1",
7172
"@types/degit": "^2.8.6",
7273
"@types/js-yaml": "^4.0.9",
7374
"degit": "^2.8.4",

packages/openapi-typescript/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export default async function openapiTS(
7777
enumValues: options.enumValues ?? false,
7878
dedupeEnums: options.dedupeEnums ?? false,
7979
excludeDeprecated: options.excludeDeprecated ?? false,
80+
experimentalArraySpreadMembers: options.experimentalArraySpreadMembers ?? false,
8081
exportType: options.exportType ?? false,
8182
immutable: options.immutable ?? false,
8283
rootTypes: options.rootTypes ?? false,

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

+6-17
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export function resolveRef<T>(
155155
return node;
156156
}
157157

158-
function createDiscriminatorEnum(values: string[], prevSchema?: SchemaObject): SchemaObject {
158+
function createDiscriminatorEnum(values: string[], prevSchema?: SchemaObject | ReferenceObject): SchemaObject {
159159
return {
160160
type: "string",
161161
enum: values,
@@ -167,7 +167,7 @@ function createDiscriminatorEnum(values: string[], prevSchema?: SchemaObject): S
167167

168168
/** Adds or replaces the discriminator enum with the passed `values` in a schema defined by `ref` */
169169
function patchDiscriminatorEnum(
170-
schema: SchemaObject,
170+
schema: OpenAPI3,
171171
ref: string,
172172
values: string[],
173173
discriminator: DiscriminatorObject,
@@ -206,7 +206,7 @@ function patchDiscriminatorEnum(
206206
// add/replace the discriminator enum property
207207
resolvedSchema.properties[discriminator.propertyName] = createDiscriminatorEnum(
208208
values,
209-
resolvedSchema.properties[discriminator.propertyName] as SchemaObject,
209+
resolvedSchema.properties[discriminator.propertyName],
210210
);
211211

212212
return true;
@@ -250,7 +250,7 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
250250
return;
251251
}
252252

253-
const oneOf: (SchemaObject | ReferenceObject)[] = obj.oneOf;
253+
const oneOf = obj.oneOf as readonly (SchemaObject | ReferenceObject)[];
254254
const mapping: InternalDiscriminatorMapping = {};
255255

256256
// the mapping can be inferred from the oneOf refs next to the discriminator object
@@ -301,9 +301,7 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
301301
// biome-ignore lint/style/noNonNullAssertion: we just checked for this
302302
const mappedValues = defined ?? [inferred!];
303303

304-
if (
305-
patchDiscriminatorEnum(schema as unknown as SchemaObject, mappedRef, mappedValues, discriminator, ref, options)
306-
) {
304+
if (patchDiscriminatorEnum(schema, mappedRef, mappedValues, discriminator, ref, options)) {
307305
refsHandled.push(mappedRef);
308306
}
309307
}
@@ -335,16 +333,7 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
335333
}
336334

337335
if (mappedValues.length > 0) {
338-
if (
339-
patchDiscriminatorEnum(
340-
schema as unknown as SchemaObject,
341-
ref,
342-
mappedValues,
343-
discriminator,
344-
item.$ref,
345-
options,
346-
)
347-
) {
336+
if (patchDiscriminatorEnum(schema, ref, mappedValues, discriminator, item.$ref, options)) {
348337
refsHandled.push(ref);
349338
}
350339
}
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Do not add any other lines of code to this file!
2+
import "@total-typescript/ts-reset";

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

+94-68
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
tsWithRequired,
2626
} from "../lib/ts.js";
2727
import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js";
28-
import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";
28+
import type { ArraySubtype, ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";
2929

3030
/**
3131
* Transform SchemaObject nodes (4.8.24)
@@ -273,6 +273,97 @@ export function transformSchemaObjectWithComposition(
273273
return finalType;
274274
}
275275

276+
type ArraySchemaObject = SchemaObject & ArraySubtype;
277+
function isArraySchemaObject(schemaObject: SchemaObject | ArraySchemaObject): schemaObject is ArraySchemaObject {
278+
return schemaObject.type === "array";
279+
}
280+
281+
/**
282+
* Return an array of tuple members of the given length, either by trimming
283+
* the prefixItems, or by padding out the end of prefixItems with itemType
284+
* @param prefixTypes The array before any padding occurs
285+
* @param length The length of the returned array
286+
* @param itemType The type to pad out the end of the array with
287+
*/
288+
function padTupleMembers(prefixTypes: readonly ts.TypeNode[], length: number, itemType: ts.TypeNode) {
289+
return Array.from({ length }).map((_, index) => (index < prefixTypes.length ? prefixTypes[index] : itemType));
290+
}
291+
292+
function toOptionsReadonly<TMembers extends ts.ArrayTypeNode | ts.TupleTypeNode>(
293+
members: TMembers,
294+
options: TransformNodeOptions,
295+
): TMembers | ts.TypeOperatorNode {
296+
return options.ctx.immutable ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, members) : members;
297+
}
298+
299+
/* Transform Array schema object */
300+
function transformArraySchemaObject(
301+
schemaObject: ArraySchemaObject,
302+
options: TransformNodeOptions,
303+
): ts.TypeNode | undefined {
304+
const prefixTypes = (schemaObject.prefixItems ?? []).map((item) => transformSchemaObject(item, options));
305+
306+
if (Array.isArray(schemaObject.items)) {
307+
return ts.factory.createTupleTypeNode(
308+
schemaObject.items.map((tupleItem) => transformSchemaObject(tupleItem, options)),
309+
);
310+
}
311+
312+
const itemType =
313+
// @ts-expect-error TS2367
314+
schemaObject.items === false
315+
? undefined
316+
: schemaObject.items
317+
? transformSchemaObject(schemaObject.items, options)
318+
: UNKNOWN;
319+
320+
// The minimum number of tuple members in the return value
321+
const min: number =
322+
options.ctx.arrayLength && typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0
323+
? schemaObject.minItems
324+
: 0;
325+
const max: number | undefined =
326+
options.ctx.arrayLength &&
327+
typeof schemaObject.maxItems === "number" &&
328+
schemaObject.maxItems >= 0 &&
329+
min <= schemaObject.maxItems
330+
? schemaObject.maxItems
331+
: undefined;
332+
333+
// "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
334+
const MAX_CODE_SIZE = 30;
335+
const estimateCodeSize = max === undefined ? min : (max * (max + 1) - min * (min - 1)) / 2;
336+
const shouldGeneratePermutations = (min !== 0 || max !== undefined) && estimateCodeSize < MAX_CODE_SIZE;
337+
338+
// if maxItems is set, then return a union of all permutations of possible tuple types
339+
if (shouldGeneratePermutations && max !== undefined && itemType) {
340+
return tsUnion(
341+
Array.from({ length: max - min + 1 }).map((_, index) => {
342+
return toOptionsReadonly(
343+
ts.factory.createTupleTypeNode(padTupleMembers(prefixTypes, index + min, itemType)),
344+
options,
345+
);
346+
}),
347+
);
348+
}
349+
350+
// if maxItems not set, then return a simple tuple type the length of `min`
351+
const spreadType = itemType ? ts.factory.createArrayTypeNode(itemType) : undefined;
352+
const tupleType =
353+
shouldGeneratePermutations || prefixTypes.length
354+
? ts.factory.createTupleTypeNode(
355+
[
356+
...(itemType ? padTupleMembers(prefixTypes, Math.max(min, prefixTypes.length), itemType) : prefixTypes),
357+
spreadType && (prefixTypes.length ? options.ctx.experimentalArraySpreadMembers : true)
358+
? ts.factory.createRestTypeNode(toOptionsReadonly(spreadType, options))
359+
: undefined,
360+
].filter(Boolean),
361+
)
362+
: spreadType;
363+
364+
return tupleType ? toOptionsReadonly(tupleType, options) : undefined;
365+
}
366+
276367
/**
277368
* Handle SchemaObject minus composition (anyOf/allOf/oneOf)
278369
*/
@@ -312,73 +403,8 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
312403
}
313404

314405
// type: array (with support for tuples)
315-
if (schemaObject.type === "array") {
316-
// default to `unknown[]`
317-
let itemType: ts.TypeNode = UNKNOWN;
318-
// tuple type
319-
if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) {
320-
const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]);
321-
itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options)));
322-
}
323-
// standard array type
324-
else if (schemaObject.items) {
325-
if ("type" in schemaObject.items && schemaObject.items.type === "array") {
326-
itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options));
327-
} else {
328-
itemType = transformSchemaObject(schemaObject.items, options);
329-
}
330-
}
331-
332-
const min: number =
333-
typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0 ? schemaObject.minItems : 0;
334-
const max: number | undefined =
335-
typeof schemaObject.maxItems === "number" && schemaObject.maxItems >= 0 && min <= schemaObject.maxItems
336-
? schemaObject.maxItems
337-
: undefined;
338-
const estimateCodeSize = typeof max !== "number" ? min : (max * (max + 1) - min * (min - 1)) / 2;
339-
if (
340-
options.ctx.arrayLength &&
341-
(min !== 0 || max !== undefined) &&
342-
estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
343-
) {
344-
if (min === max) {
345-
const elements: ts.TypeNode[] = [];
346-
for (let i = 0; i < min; i++) {
347-
elements.push(itemType);
348-
}
349-
return tsUnion([ts.factory.createTupleTypeNode(elements)]);
350-
} else if ((schemaObject.maxItems as number) > 0) {
351-
// if maxItems is set, then return a union of all permutations of possible tuple types
352-
const members: ts.TypeNode[] = [];
353-
// populate 1 short of min …
354-
for (let i = 0; i <= (max ?? 0) - min; i++) {
355-
const elements: ts.TypeNode[] = [];
356-
for (let j = min; j < i + min; j++) {
357-
elements.push(itemType);
358-
}
359-
members.push(ts.factory.createTupleTypeNode(elements));
360-
}
361-
return tsUnion(members);
362-
}
363-
// if maxItems not set, then return a simple tuple type the length of `min`
364-
else {
365-
const elements: ts.TypeNode[] = [];
366-
for (let i = 0; i < min; i++) {
367-
elements.push(itemType);
368-
}
369-
elements.push(ts.factory.createRestTypeNode(ts.factory.createArrayTypeNode(itemType)));
370-
return ts.factory.createTupleTypeNode(elements);
371-
}
372-
}
373-
374-
const finalType =
375-
ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType)
376-
? itemType
377-
: ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already
378-
379-
return options.ctx.immutable
380-
? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType)
381-
: finalType;
406+
if (isArraySchemaObject(schemaObject)) {
407+
return transformArraySchemaObject(schemaObject, options);
382408
}
383409

384410
// polymorphic, or 3.1 nullable

packages/openapi-typescript/src/types.ts

+3
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,8 @@ export interface OpenAPITSOptions {
647647
version?: number;
648648
/** (optional) Export type instead of interface */
649649
exportType?: boolean;
650+
/** (optional) Experimental: Array schemas with prefixItems spread members */
651+
experimentalArraySpreadMembers?: boolean;
650652
/** Export true TypeScript enums instead of unions */
651653
enum?: boolean;
652654
/** Export union values as arrays */
@@ -690,6 +692,7 @@ export interface GlobalContext {
690692
enumValues: boolean;
691693
dedupeEnums: boolean;
692694
excludeDeprecated: boolean;
695+
experimentalArraySpreadMembers: boolean;
693696
exportType: boolean;
694697
immutable: boolean;
695698
injectFooter: ts.Node[];

packages/openapi-typescript/test/test-helpers.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const DEFAULT_CTX: GlobalContext = {
1515
emptyObjectsUnknown: false,
1616
enum: false,
1717
enumValues: false,
18+
experimentalArraySpreadMembers: false,
1819
dedupeEnums: false,
1920
excludeDeprecated: false,
2021
exportType: false,

0 commit comments

Comments
 (0)