diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index 7fe70e3a3..bb86dbc2b 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -16,7 +16,8 @@ import { parseAsString, parseAsStringEnum, parseAsStringLiteral, - parseAsTimestamp + parseAsTimestamp, + parseAsTuple } from './parsers' import { isParserBijective, @@ -299,6 +300,25 @@ describe('parsers', () => { isParserBijective(parser, 'not-an-array', ['a', 'b']) ).toThrow() }) + it.only('parseAsTuple', () => { + const parser = parseAsTuple([parseAsInteger, parseAsString, parseAsBoolean]) + expect(parser.parse('1,a,false,will-ignore')).toStrictEqual([1, 'a', false]) + expect(parser.parse('not-a-number,a,true')).toBeNull() + expect(parser.parse('1,a')).toBeNull() + // @ts-expect-error - Tuple length is less than 2 + expect(() => parseAsTuple([parseAsInteger])).toThrow() + expect(parser.serialize([1, 'a', true])).toBe('1,a,true') + // @ts-expect-error - Tuple length mismatch + expect(() => parser.serialize([1, 'a'])).toThrow() + expect(testParseThenSerialize(parser, '1,a,true')).toBe(true) + expect(testSerializeThenParse(parser, [1, 'a', true] as const)).toBe(true) + expect(isParserBijective(parser, '1,a,true', [1, 'a', true] as const)).toBe( + true + ) + expect(() => + isParserBijective(parser, 'not-a-tuple', [1, 'a', true] as const) + ).toThrow() + }) it('parseServerSide with default (#384)', () => { const p = parseAsString.withDefault('default') @@ -351,4 +371,14 @@ describe('parsers/equality', () => { expect(eq([], ['foo'])).toBe(false) expect(eq(['foo'], ['bar'])).toBe(false) }) + it.only('parseAsTuple', () => { + const eq = parseAsTuple([parseAsInteger, parseAsBoolean]).eq! + expect(eq([1, true], [1, true])).toBe(true) + expect(eq([1, true], [1, false])).toBe(false) + expect(eq([1, true], [2, true])).toBe(false) + // @ts-expect-error - Tuple length mismatch + expect(eq([1, true], [1])).toBe(false) + // @ts-expect-error - Tuple length mismatch + expect(eq([1], [1])).toBe(false) + }) }) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index baf57d94a..d465cb038 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -465,6 +465,78 @@ export function parseAsArrayOf( }) } +type ParserTuple = { + [K in keyof T]: ParserBuilder +} & { length: 2 | 3 | 4 | 5 | 6 | 7 | 8 } + +/** + * Parse a comma-separated tuple with type-safe positions. + * Items are URI-encoded for safety, so they may not look nice in the URL. + * allowed tuple length is 2-8. + * + * @param itemParsers Tuple of parsers for each position in the tuple + * @param separator The character to use to separate items (default ',') + */ +export function parseAsTuple( + itemParsers: ParserTuple, + separator = ',' +): ParserBuilder { + const encodedSeparator = encodeURIComponent(separator) + if (itemParsers.length < 2 || itemParsers.length > 8) { + throw new Error( + `Tuple length must be between 2 and 8, got ${itemParsers.length}` + ) + } + return createParser({ + parse: query => { + if (query === '') { + return null + } + const parts = query.split(separator) + if (parts.length < itemParsers.length) { + return null + } + // iterating by parsers instead of parts, any additional parts are ignored. + const result = itemParsers.map( + (parser, index) => + safeParse( + parser.parse, + parts[index]!.replaceAll(encodedSeparator, separator), + `[${index}]` + ) as T[number] | null + ) + return result.some(x => x === null) ? null : (result as T) + }, + serialize: (values: T) => { + if (values.length !== itemParsers.length) { + throw new Error( + `Tuple length mismatch: expected ${itemParsers.length}, got ${values.length}` + ) + } + return values + .map((value, index) => { + const parser = itemParsers[index]! + const str = parser.serialize ? parser.serialize(value) : String(value) + return str.replaceAll(separator, encodedSeparator) + }) + .join(separator) + }, + eq(a: T, b: T) { + if (a === b) { + return true + } + if (a.length !== b.length || a.length !== itemParsers.length) { + return false + } + return a.every((value, index) => { + const parser = itemParsers[index]! + const itemEq = parser.eq ?? ((x, y) => x === y) + return itemEq(value, b[index]) + }) + } + }) +} + type inferSingleParserType = Parser extends ParserBuilder< infer Value > & {