Skip to content

Commit bf14226

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

File tree

6 files changed

+474
-86
lines changed

6 files changed

+474
-86
lines changed

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",
+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) =>
290+
index < prefixTypes.length ? prefixTypes[index] : itemType
291+
);
292+
}
293+
294+
function toOptionsReadonly<TMembers extends ts.ArrayTypeNode | ts.TupleTypeNode>(
295+
members: TMembers,
296+
options: TransformNodeOptions,
297+
): TMembers | ts.TypeOperatorNode {
298+
return options.ctx.immutable ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, members) : members;
299+
}
300+
301+
/* Transform Array schema object */
302+
function transformArraySchemaObject(
303+
schemaObject: ArraySchemaObject,
304+
options: TransformNodeOptions,
305+
): ts.TypeNode | undefined {
306+
const prefixTypes = (schemaObject.prefixItems ?? []).map((item) => transformSchemaObject(item, options));
307+
308+
if (Array.isArray(schemaObject.items)) {
309+
return ts.factory.createTupleTypeNode(
310+
schemaObject.items.map((tupleItem) => transformSchemaObject(tupleItem, options)),
311+
);
312+
}
313+
314+
const itemType =
315+
// @ts-expect-error TS2367
316+
schemaObject.items === false
317+
? undefined
318+
: schemaObject.items
319+
? transformSchemaObject(schemaObject.items, options)
320+
: UNKNOWN;
321+
322+
// The minimum number of tuple members in the return value
323+
const min: number =
324+
options.ctx.arrayLength && typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0
325+
? schemaObject.minItems
326+
: 0;
327+
const max: number | undefined =
328+
options.ctx.arrayLength &&
329+
typeof schemaObject.maxItems === "number" &&
330+
schemaObject.maxItems >= 0 &&
331+
min <= schemaObject.maxItems
332+
? schemaObject.maxItems
333+
: undefined;
334+
335+
// "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
336+
const MAX_CODE_SIZE = 30;
337+
const estimateCodeSize = max === undefined ? min : (max * (max + 1) - min * (min - 1)) / 2;
338+
const shouldGeneratePermutations = (min !== 0 || max !== undefined) && estimateCodeSize < MAX_CODE_SIZE;
339+
340+
// if maxItems is set, then return a union of all permutations of possible tuple types
341+
if (shouldGeneratePermutations && max !== undefined && itemType) {
342+
return tsUnion(
343+
Array.from({ length: max - min + 1 }).map((_, index) => {
344+
return toOptionsReadonly(
345+
ts.factory.createTupleTypeNode(padTupleMembers(prefixTypes, index + min, itemType)),
346+
options,
347+
);
348+
}),
349+
);
350+
}
351+
352+
// if maxItems not set, then return a simple tuple type the length of `min`
353+
const spreadType = itemType ? ts.factory.createArrayTypeNode(itemType) : undefined;
354+
const tupleType =
355+
shouldGeneratePermutations || prefixTypes.length
356+
? ts.factory.createTupleTypeNode(
357+
[
358+
...(itemType ? padTupleMembers(prefixTypes, Math.max(min, prefixTypes.length), itemType) : prefixTypes),
359+
spreadType ? ts.factory.createRestTypeNode(toOptionsReadonly(spreadType, options)) : 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

0 commit comments

Comments
 (0)