diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts index f2622251e..238177560 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts @@ -380,6 +380,87 @@ describe('convertSchema', () => { }); }); + test('should not require optional items at the end of tuple schema', () => { + expect( + convertSchema( + {}, + v.tuple([v.string(), v.optional(v.number()), v.nullish(v.boolean())]), + undefined, + createContext() + ) + ).toStrictEqual({ + type: 'array', + items: [ + { type: 'string' }, + { type: 'number' }, + { anyOf: [{ type: 'boolean' }, { type: 'null' }] }, + ], + minItems: 1, + }); + expect( + convertSchema( + {}, + v.strictTuple([v.optional(v.string()), v.number()]), + undefined, + createContext() + ) + ).toStrictEqual({ + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + minItems: 2, + additionalItems: false, + }); + expect( + convertSchema( + {}, + v.tuple([ + v.string(), + v.exactOptional(v.number()), + v.undefinedable(v.boolean()), + ]), + undefined, + createContext() + ) + ).toStrictEqual({ + type: 'array', + items: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + minItems: 3, + }); + }); + + test('should not require optional items at the end of tuple schema for draft-2020-12', () => { + expect( + convertSchema( + {}, + v.tupleWithRest([v.string(), v.optional(v.number())], v.boolean()), + { target: 'draft-2020-12' }, + createContext() + ) + ).toStrictEqual({ + type: 'array', + prefixItems: [{ type: 'string' }, { type: 'number' }], + minItems: 1, + items: { type: 'boolean' }, + }); + }); + + test('should not require optional items at the end of tuple schema for openapi-3.0', () => { + expect( + convertSchema( + {}, + v.looseTuple([v.string(), v.optional(v.number())]), + { target: 'openapi-3.0' }, + createContext() + ) + ).toStrictEqual({ + type: 'array', + items: { + anyOf: [{ type: 'string' }, { type: 'number' }], + }, + minItems: 1, + }); + }); + test('should convert tuple with rest schema', () => { expect( convertSchema( diff --git a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts index 224399e90..66081276f 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.ts @@ -305,11 +305,22 @@ export function convertSchema( case 'strict_tuple': { jsonSchema.type = 'array'; + // Get minimum number of items + // Hint: Optional items at the end of a tuple can be omitted + let minItems = valibotSchema.items.length; + while ( + minItems > 0 && + (valibotSchema.items[minItems - 1].type === 'optional' || + valibotSchema.items[minItems - 1].type === 'nullish') + ) { + minItems--; + } + // If target is OpenAPI 3.0 if (config?.target === 'openapi-3.0') { // Uses anyOf pattern with minItems/maxItems jsonSchema.items = { anyOf: [] }; - jsonSchema.minItems = valibotSchema.items.length; + jsonSchema.minItems = minItems; for (const item of valibotSchema.items) { // @ts-expect-error jsonSchema.items.anyOf.push( @@ -341,7 +352,7 @@ export function convertSchema( } else if (config?.target === 'draft-2020-12') { // Use prefixItems for draft-2020-12 jsonSchema.prefixItems = []; - jsonSchema.minItems = valibotSchema.items.length; + jsonSchema.minItems = minItems; for (const item of valibotSchema.items) { jsonSchema.prefixItems.push( convertSchema({}, item as SchemaOrPipe, config, context) @@ -364,7 +375,7 @@ export function convertSchema( } else { // Use items array for draft-07 jsonSchema.items = []; - jsonSchema.minItems = valibotSchema.items.length; + jsonSchema.minItems = minItems; for (const item of valibotSchema.items) { jsonSchema.items.push( convertSchema({}, item as SchemaOrPipe, config, context)