Skip to content

Commit 6a08b34

Browse files
Fix prefixItems / minItems / maxItems tuple generation (#2053)
* Simplify minItems / maxItems tuple generation Closes #2048 * fixup! Simplify minItems / maxItems tuple generation Account for immutable: true * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation * fixup! Simplify minItems / maxItems tuple generation
1 parent bf5d6f8 commit 6a08b34

File tree

5 files changed

+452
-83
lines changed

5 files changed

+452
-83
lines changed

Diff for: .changeset/clean-phones-deliver.md

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

Diff for: packages/openapi-typescript/examples/simple-example.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ export interface operations {
245245
};
246246
content: {
247247
"application/json": [
248-
string
248+
string,
249+
...unknown[]
249250
];
250251
};
251252
};

Diff for: packages/openapi-typescript/src/transform/schema-object.ts

+72-68
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
UNDEFINED,
1111
UNKNOWN,
1212
addJSDocComment,
13+
astToString,
1314
oapiRef,
1415
tsArrayLiteralExpression,
1516
tsEnum,
@@ -25,7 +26,7 @@ import {
2526
tsWithRequired,
2627
} from "../lib/ts.js";
2728
import { createDiscriminatorProperty, createRef, getEntries } from "../lib/utils.js";
28-
import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";
29+
import type { ArraySubtype, ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js";
2930

3031
/**
3132
* Transform SchemaObject nodes (4.8.24)
@@ -273,6 +274,74 @@ export function transformSchemaObjectWithComposition(
273274
return finalType;
274275
}
275276

277+
type ArraySchemaObject = SchemaObject & ArraySubtype;
278+
function isArraySchemaObject(schemaObject: SchemaObject | ArraySchemaObject): schemaObject is ArraySchemaObject {
279+
return schemaObject.type === "array";
280+
}
281+
282+
function padTupleMembers(length: number, itemType: ts.TypeNode, prefixTypes: readonly ts.TypeNode[]) {
283+
return Array.from({ length }).map((_, index) => {
284+
return prefixTypes[index] ?? itemType;
285+
});
286+
}
287+
288+
function toOptionsReadonly<TMembers extends ts.ArrayTypeNode | ts.TupleTypeNode>(
289+
members: TMembers,
290+
options: TransformNodeOptions,
291+
): TMembers | ts.TypeOperatorNode {
292+
return options.ctx.immutable ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, members) : members;
293+
}
294+
295+
/* Transform Array schema object */
296+
function transformArraySchemaObject(schemaObject: ArraySchemaObject, options: TransformNodeOptions): ts.TypeNode {
297+
const prefixTypes = (schemaObject.prefixItems ?? []).map((item) => transformSchemaObject(item, options));
298+
299+
if (Array.isArray(schemaObject.items)) {
300+
throw new Error(`${options.path}: invalid property items. Expected Schema Object, got Array`);
301+
}
302+
303+
const itemType = schemaObject.items ? transformSchemaObject(schemaObject.items, options) : UNKNOWN;
304+
305+
// The minimum number of tuple members to return
306+
const min: number =
307+
options.ctx.arrayLength && typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0
308+
? schemaObject.minItems
309+
: 0;
310+
const max: number | undefined =
311+
options.ctx.arrayLength &&
312+
typeof schemaObject.maxItems === "number" &&
313+
schemaObject.maxItems >= 0 &&
314+
min <= schemaObject.maxItems
315+
? schemaObject.maxItems
316+
: undefined;
317+
318+
// "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
319+
const MAX_CODE_SIZE = 30;
320+
const estimateCodeSize = max === undefined ? min : (max * (max + 1) - min * (min - 1)) / 2;
321+
const shouldGeneratePermutations = (min !== 0 || max !== undefined) && estimateCodeSize < MAX_CODE_SIZE;
322+
323+
// if maxItems is set, then return a union of all permutations of possible tuple types
324+
if (shouldGeneratePermutations && max !== undefined) {
325+
return tsUnion(
326+
Array.from({ length: max - min + 1 }).map((_, index) =>
327+
toOptionsReadonly(ts.factory.createTupleTypeNode(padTupleMembers(index + min, itemType, prefixTypes)), options),
328+
),
329+
);
330+
}
331+
332+
// if maxItems not set, then return a simple tuple type the length of `min`
333+
const spreadType = ts.factory.createArrayTypeNode(itemType);
334+
const tupleType =
335+
shouldGeneratePermutations || prefixTypes.length
336+
? ts.factory.createTupleTypeNode([
337+
...padTupleMembers(Math.max(min, prefixTypes.length), itemType, prefixTypes),
338+
ts.factory.createRestTypeNode(toOptionsReadonly(spreadType, options)),
339+
])
340+
: spreadType;
341+
342+
return toOptionsReadonly(tupleType, options);
343+
}
344+
276345
/**
277346
* Handle SchemaObject minus composition (anyOf/allOf/oneOf)
278347
*/
@@ -312,73 +381,8 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
312381
}
313382

314383
// 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;
384+
if (isArraySchemaObject(schemaObject)) {
385+
return transformArraySchemaObject(schemaObject, options);
382386
}
383387

384388
// polymorphic, or 3.1 nullable

0 commit comments

Comments
 (0)