Skip to content

Commit 44eb2fc

Browse files
committed
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.
1 parent c074cdd commit 44eb2fc

File tree

3 files changed

+215
-9
lines changed

3 files changed

+215
-9
lines changed

.changeset/itchy-hands-give.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript": patch
3+
---
4+
5+
Fix array minItems/maxItems bugs

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -346,18 +346,16 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
346346
if (schemaObject.type === "array") {
347347
// default to `unknown[]`
348348
let itemType: ts.TypeNode = UNKNOWN;
349+
let isTupleType = false;
349350
// tuple type
350351
if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) {
351352
const prefixItems = schemaObject.prefixItems ?? (schemaObject.items as (SchemaObject | ReferenceObject)[]);
352353
itemType = ts.factory.createTupleTypeNode(prefixItems.map((item) => transformSchemaObject(item, options)));
354+
isTupleType = true;
353355
}
354356
// standard array type
355357
else if (schemaObject.items) {
356-
if (hasKey(schemaObject.items, "type") && schemaObject.items.type === "array") {
357-
itemType = ts.factory.createArrayTypeNode(transformSchemaObject(schemaObject.items, options));
358-
} else {
359-
itemType = transformSchemaObject(schemaObject.items, options);
360-
}
358+
itemType = transformSchemaObject(schemaObject.items, options);
361359
}
362360

363361
const min: number =
@@ -401,10 +399,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
401399
}
402400
}
403401

404-
const finalType =
405-
ts.isTupleTypeNode(itemType) || ts.isArrayTypeNode(itemType)
406-
? itemType
407-
: ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple or array already
402+
const finalType = isTupleType ? itemType : ts.factory.createArrayTypeNode(itemType);
408403

409404
return options.ctx.immutable
410405
? ts.factory.createTypeOperatorNode(ts.SyntaxKind.ReadonlyKeyword, finalType)

packages/openapi-typescript/test/transform/schema-object/array.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,212 @@ describe("transformSchemaObject > array", () => {
264264
},
265265
},
266266
],
267+
[
268+
"options > arrayLength: true > nested array with minItems equals maxItems",
269+
{
270+
given: {
271+
type: "array",
272+
items: { type: "array", items: { type: "string" } },
273+
minItems: 2,
274+
maxItems: 2,
275+
},
276+
want: `[
277+
string[],
278+
string[]
279+
]`,
280+
options: {
281+
...DEFAULT_OPTIONS,
282+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
283+
},
284+
},
285+
],
286+
[
287+
"options > arrayLength: true > nested array with inner minItems equals maxItems",
288+
{
289+
given: {
290+
type: "array",
291+
items: {
292+
type: "array",
293+
items: { type: "string" },
294+
minItems: 3,
295+
maxItems: 3,
296+
},
297+
},
298+
want: `[
299+
string,
300+
string,
301+
string
302+
][]`,
303+
options: {
304+
...DEFAULT_OPTIONS,
305+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
306+
},
307+
},
308+
],
309+
[
310+
"options > arrayLength: true > triple nested array",
311+
{
312+
given: {
313+
type: "array",
314+
items: {
315+
type: "array",
316+
items: { type: "array", items: { type: "string" } },
317+
},
318+
},
319+
want: "string[][][]",
320+
options: {
321+
...DEFAULT_OPTIONS,
322+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
323+
},
324+
},
325+
],
326+
[
327+
"options > arrayLength: true > nested array with both inner and outer constraints",
328+
{
329+
given: {
330+
type: "array",
331+
items: {
332+
type: "array",
333+
items: { type: "boolean" },
334+
minItems: 2,
335+
maxItems: 2,
336+
},
337+
minItems: 3,
338+
maxItems: 3,
339+
},
340+
want: `[
341+
[
342+
boolean,
343+
boolean
344+
],
345+
[
346+
boolean,
347+
boolean
348+
],
349+
[
350+
boolean,
351+
boolean
352+
]
353+
]`,
354+
options: {
355+
...DEFAULT_OPTIONS,
356+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
357+
},
358+
},
359+
],
360+
[
361+
"options > arrayLength: true > nested array without constraints",
362+
{
363+
given: {
364+
type: "array",
365+
items: { type: "array", items: { type: "number" } },
366+
},
367+
want: "number[][]",
368+
options: {
369+
...DEFAULT_OPTIONS,
370+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
371+
},
372+
},
373+
],
374+
[
375+
"options > arrayLength: true > nested tuple (prefixItems) in array",
376+
{
377+
given: {
378+
type: "array",
379+
items: {
380+
type: "array",
381+
prefixItems: [{ type: "string" }, { type: "number" }],
382+
},
383+
minItems: 2,
384+
maxItems: 2,
385+
},
386+
want: `[
387+
[
388+
string,
389+
number
390+
],
391+
[
392+
string,
393+
number
394+
]
395+
]`,
396+
options: {
397+
...DEFAULT_OPTIONS,
398+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
399+
},
400+
},
401+
],
402+
[
403+
"options > arrayLength: true > nested array with minItems: 0, maxItems: 2",
404+
{
405+
given: {
406+
type: "array",
407+
items: { type: "array", items: { type: "string" } },
408+
minItems: 0,
409+
maxItems: 2,
410+
},
411+
want: `[
412+
] | [
413+
string[]
414+
] | [
415+
string[],
416+
string[]
417+
]`,
418+
options: {
419+
...DEFAULT_OPTIONS,
420+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
421+
},
422+
},
423+
],
424+
[
425+
"options > arrayLength: true > deeply nested with constraints at multiple levels",
426+
{
427+
given: {
428+
type: "array",
429+
items: {
430+
type: "array",
431+
items: {
432+
type: "array",
433+
items: { type: "number" },
434+
minItems: 2,
435+
maxItems: 2,
436+
},
437+
},
438+
minItems: 1,
439+
maxItems: 1,
440+
},
441+
want: `[
442+
[
443+
number,
444+
number
445+
][]
446+
]`,
447+
options: {
448+
...DEFAULT_OPTIONS,
449+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
450+
},
451+
},
452+
],
453+
[
454+
"options > arrayLength: true > array of tuples without outer constraints",
455+
{
456+
given: {
457+
type: "array",
458+
items: {
459+
type: "array",
460+
prefixItems: [{ type: "string" }, { type: "boolean" }],
461+
},
462+
},
463+
want: `[
464+
string,
465+
boolean
466+
][]`,
467+
options: {
468+
...DEFAULT_OPTIONS,
469+
ctx: { ...DEFAULT_OPTIONS.ctx, arrayLength: true },
470+
},
471+
},
472+
],
267473
[
268474
"options > immutable: true",
269475
{

0 commit comments

Comments
 (0)