From c074cdd80aad755e977e432ad4d0ee07a1e2d9b7 Mon Sep 17 00:00:00 2001 From: Simon Solnes Date: Wed, 10 Dec 2025 22:33:07 +0100 Subject: [PATCH 1/2] fix(openapi-typescript): fix array minItems/maxItems tuple generation algorithm The loop was incorrectly generating tuples from 0 to (max-min) elements instead of min to max elements. For example, minItems: 1, maxItems: 3 was producing [] | [T] | [T, T] instead of [T] | [T, T] | [T, T, T]. --- .../src/transform/schema-object.ts | 5 +- .../transform/schema-object/array.test.ts | 96 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 1f078d533..61d726ca7 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -381,10 +381,9 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } else if ((schemaObject.maxItems as number) > 0) { // if maxItems is set, then return a union of all permutations of possible tuple types const members: ts.TypeNode[] = []; - // populate 1 short of min … - for (let i = 0; i <= (max ?? 0) - min; i++) { + for (let i = min; i <= (max ?? 0); i++) { const elements: ts.TypeNode[] = []; - for (let j = min; j < i + min; j++) { + for (let j = 0; j < i; j++) { elements.push(itemType); } members.push(ts.factory.createTupleTypeNode(elements)); diff --git a/packages/openapi-typescript/test/transform/schema-object/array.test.ts b/packages/openapi-typescript/test/transform/schema-object/array.test.ts index fe3e53358..8a7f2ff87 100644 --- a/packages/openapi-typescript/test/transform/schema-object/array.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/array.test.ts @@ -161,6 +161,102 @@ describe("transformSchemaObject > array", () => { want: `[ string, string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > minItems: 1, maxItems: 3", + { + given: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 3 }, + want: `[ + string +] | [ + string, + string +] | [ + string, + string, + string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > minItems: 0, maxItems: 2 (starts from empty)", + { + given: { type: "array", items: { type: "string" }, minItems: 0, maxItems: 2 }, + want: `[ +] | [ + string +] | [ + string, + string +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > minItems: 3, maxItems: 5 (larger range)", + { + given: { type: "array", items: { type: "number" }, minItems: 3, maxItems: 5 }, + want: `[ + number, + number, + number +] | [ + number, + number, + number, + number +] | [ + number, + number, + number, + number, + number +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > minItems: 2, maxItems: 3 with object items", + { + given: { + type: "array", + items: { type: "object", properties: { id: { type: "string" } } }, + minItems: 2, + maxItems: 3, + }, + want: `[ + { + id?: string; + }, + { + id?: string; + } +] | [ + { + id?: string; + }, + { + id?: string; + }, + { + id?: string; + } ]`, options: { ...DEFAULT_OPTIONS, From 2c1266efac46e1ddc2c86ef2df329c854e883f83 Mon Sep 17 00:00:00 2001 From: Simon Solnes Date: Wed, 10 Dec 2025 22:38:00 +0100 Subject: [PATCH 2/2] fix(openapi-typescript): fix nested array nesting with --array-length flag When an array's items was also an array type with minItems equal to maxItems, the generated TypeScript was getting incorrectly double-nested (e.g. string[][] instead of string[]). The issue was that the code checked if itemType was already an array/tuple to skip wrapping, but this incorrectly skipped wrapping for standard arrays when the nested transform happened to return an array type. Now we track whether the current schema defines a tuple (via prefixItems) separately. --- .../src/transform/schema-object.ts | 13 +- .../transform/schema-object/array.test.ts | 206 ++++++++++++++++++ 2 files changed, 210 insertions(+), 9 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 61d726ca7..883be8d13 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -346,18 +346,16 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor if (schemaObject.type === "array") { // default to `unknown[]` let itemType: ts.TypeNode = UNKNOWN; + let isTupleType = false; // tuple type if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) { const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]); itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options))); + isTupleType = true; } // standard array type else if (schemaObject.items) { - if (hasKey(schemaObject.items, "type") && schemaObject.items.type === "array") { - itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options)); - } else { - itemType = transformSchemaObject(schemaObject.items, options); - } + itemType = transformSchemaObject(schemaObject.items, options); } const min: number = @@ -401,10 +399,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } } - const finalType = - ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType) - ? itemType - : ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already + const finalType = isTupleType ? itemType : ts.factory.createArrayTypeNode(itemType); return options.ctx.immutable ? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType) diff --git a/packages/openapi-typescript/test/transform/schema-object/array.test.ts b/packages/openapi-typescript/test/transform/schema-object/array.test.ts index 8a7f2ff87..de53c4143 100644 --- a/packages/openapi-typescript/test/transform/schema-object/array.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/array.test.ts @@ -264,6 +264,212 @@ describe("transformSchemaObject > array", () => { }, }, ], + [ + "options > arrayLength: true > nested array with minItems equals maxItems", + { + given: { + type: "array", + items: { type: "array", items: { type: "string" } }, + minItems: 2, + maxItems: 2, + }, + want: `[ + string[], + string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > nested array with inner minItems equals maxItems", + { + given: { + type: "array", + items: { + type: "array", + items: { type: "string" }, + minItems: 3, + maxItems: 3, + }, + }, + want: `[ + string, + string, + string +][]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > triple nested array", + { + given: { + type: "array", + items: { + type: "array", + items: { type: "array", items: { type: "string" } }, + }, + }, + want: "string[][][]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > nested array with both inner and outer constraints", + { + given: { + type: "array", + items: { + type: "array", + items: { type: "boolean" }, + minItems: 2, + maxItems: 2, + }, + minItems: 3, + maxItems: 3, + }, + want: `[ + [ + boolean, + boolean + ], + [ + boolean, + boolean + ], + [ + boolean, + boolean + ] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > nested array without constraints", + { + given: { + type: "array", + items: { type: "array", items: { type: "number" } }, + }, + want: "number[][]", + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > nested tuple (prefixItems) in array", + { + given: { + type: "array", + items: { + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + }, + minItems: 2, + maxItems: 2, + }, + want: `[ + [ + string, + number + ], + [ + string, + number + ] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > nested array with minItems: 0, maxItems: 2", + { + given: { + type: "array", + items: { type: "array", items: { type: "string" } }, + minItems: 0, + maxItems: 2, + }, + want: `[ +] | [ + string[] +] | [ + string[], + string[] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > deeply nested with constraints at multiple levels", + { + given: { + type: "array", + items: { + type: "array", + items: { + type: "array", + items: { type: "number" }, + minItems: 2, + maxItems: 2, + }, + }, + minItems: 1, + maxItems: 1, + }, + want: `[ + [ + number, + number + ][] +]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], + [ + "options > arrayLength: true > array of tuples without outer constraints", + { + given: { + type: "array", + items: { + type: "array", + prefixItems: [{ type: "string" }, { type: "boolean" }], + }, + }, + want: `[ + string, + boolean +][]`, + options: { + ...DEFAULT_OPTIONS, + ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true }, + }, + }, + ], [ "options > immutable: true", {