From 89815385b597567e4bdf8075a06b9be77f6a3ed4 Mon Sep 17 00:00:00 2001 From: Yash Dogra Date: Mon, 9 Nov 2026 03:24:54 +0530 Subject: [PATCH 1/2] fix(to-json-schema): do not require optional items at the end of tuples Tuple schemas set minItems to the number of items, so trailing optional and nullish items became required in the JSON Schema. Valibot accepts tuples where those items are omitted, so minItems now stops at the last item that is not optional or nullish. --- .../convertSchema/convertSchema.test.ts | 65 +++++++++++++++++++ .../converters/convertSchema/convertSchema.ts | 17 ++++- 2 files changed, 79 insertions(+), 3 deletions(-) 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..f469885aa 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,71 @@ 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, + }); + }); + + 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) From 11f3fc0763a012ee80a365c3689a8f9ab0ebf5f2 Mon Sep 17 00:00:00 2001 From: yxshee <84263676+yxshee@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:54:15 +0530 Subject: [PATCH 2/2] test(to-json-schema): pin exactOptional and undefinedable tuple items as required --- .../convertSchema/convertSchema.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 f469885aa..238177560 100644 --- a/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts +++ b/packages/to-json-schema/src/converters/convertSchema/convertSchema.test.ts @@ -410,6 +410,22 @@ describe('convertSchema', () => { 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', () => {