Skip to content

Commit 75ec4db

Browse files
committed
feat: parseAsTuple (#1036)
1 parent f6e2902 commit 75ec4db

File tree

2 files changed

+103
-1
lines changed

2 files changed

+103
-1
lines changed

packages/nuqs/src/parsers.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
parseAsString,
1717
parseAsStringEnum,
1818
parseAsStringLiteral,
19-
parseAsTimestamp
19+
parseAsTimestamp,
20+
parseAsTuple
2021
} from './parsers'
2122
import {
2223
isParserBijective,
@@ -299,6 +300,25 @@ describe('parsers', () => {
299300
isParserBijective(parser, 'not-an-array', ['a', 'b'])
300301
).toThrow()
301302
})
303+
it.only('parseAsTuple', () => {
304+
const parser = parseAsTuple([parseAsInteger, parseAsString, parseAsBoolean])
305+
expect(parser.parse('1,a,false,will-ignore')).toStrictEqual([1, 'a', false])
306+
expect(parser.parse('not-a-number,a,true')).toBeNull()
307+
expect(parser.parse('1,a')).toBeNull()
308+
// @ts-expect-error - Tuple length is less than 2
309+
expect(() => parseAsTuple([parseAsInteger])).toThrow()
310+
expect(parser.serialize([1, 'a', true])).toBe('1,a,true')
311+
// @ts-expect-error - Tuple length mismatch
312+
expect(() => parser.serialize([1, 'a'])).toThrow()
313+
expect(testParseThenSerialize(parser, '1,a,true')).toBe(true)
314+
expect(testSerializeThenParse(parser, [1, 'a', true] as const)).toBe(true)
315+
expect(isParserBijective(parser, '1,a,true', [1, 'a', true] as const)).toBe(
316+
true
317+
)
318+
expect(() =>
319+
isParserBijective(parser, 'not-a-tuple', [1, 'a', true] as const)
320+
).toThrow()
321+
})
302322

303323
it('parseServerSide with default (#384)', () => {
304324
const p = parseAsString.withDefault('default')
@@ -351,4 +371,14 @@ describe('parsers/equality', () => {
351371
expect(eq([], ['foo'])).toBe(false)
352372
expect(eq(['foo'], ['bar'])).toBe(false)
353373
})
374+
it.only('parseAsTuple', () => {
375+
const eq = parseAsTuple([parseAsInteger, parseAsBoolean]).eq!
376+
expect(eq([1, true], [1, true])).toBe(true)
377+
expect(eq([1, true], [1, false])).toBe(false)
378+
expect(eq([1, true], [2, true])).toBe(false)
379+
// @ts-expect-error - Tuple length mismatch
380+
expect(eq([1, true], [1])).toBe(false)
381+
// @ts-expect-error - Tuple length mismatch
382+
expect(eq([1], [1])).toBe(false)
383+
})
354384
})

packages/nuqs/src/parsers.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,78 @@ export function parseAsArrayOf<ItemType>(
465465
})
466466
}
467467

468+
type ParserTuple<T extends readonly unknown[]> = {
469+
[K in keyof T]: ParserBuilder<T[K]>
470+
} & { length: 2 | 3 | 4 | 5 | 6 | 7 | 8 }
471+
472+
/**
473+
* Parse a comma-separated tuple with type-safe positions.
474+
* Items are URI-encoded for safety, so they may not look nice in the URL.
475+
* allowed tuple length is 2-8.
476+
*
477+
* @param itemParsers Tuple of parsers for each position in the tuple
478+
* @param separator The character to use to separate items (default ',')
479+
*/
480+
export function parseAsTuple<T extends any[]>(
481+
itemParsers: ParserTuple<T>,
482+
separator = ','
483+
): ParserBuilder<T> {
484+
const encodedSeparator = encodeURIComponent(separator)
485+
if (itemParsers.length < 2 || itemParsers.length > 8) {
486+
throw new Error(
487+
`Tuple length must be between 2 and 8, got ${itemParsers.length}`
488+
)
489+
}
490+
return createParser<T>({
491+
parse: query => {
492+
if (query === '') {
493+
return null
494+
}
495+
const parts = query.split(separator)
496+
if (parts.length < itemParsers.length) {
497+
return null
498+
}
499+
// iterating by parsers instead of parts, any additional parts are ignored.
500+
const result = itemParsers.map(
501+
(parser, index) =>
502+
safeParse(
503+
parser.parse,
504+
parts[index]!.replaceAll(encodedSeparator, separator),
505+
`[${index}]`
506+
) as T[number] | null
507+
)
508+
return result.some(x => x === null) ? null : (result as T)
509+
},
510+
serialize: (values: T) => {
511+
if (values.length !== itemParsers.length) {
512+
throw new Error(
513+
`Tuple length mismatch: expected ${itemParsers.length}, got ${values.length}`
514+
)
515+
}
516+
return values
517+
.map((value, index) => {
518+
const parser = itemParsers[index]!
519+
const str = parser.serialize ? parser.serialize(value) : String(value)
520+
return str.replaceAll(separator, encodedSeparator)
521+
})
522+
.join(separator)
523+
},
524+
eq(a: T, b: T) {
525+
if (a === b) {
526+
return true
527+
}
528+
if (a.length !== b.length || a.length !== itemParsers.length) {
529+
return false
530+
}
531+
return a.every((value, index) => {
532+
const parser = itemParsers[index]!
533+
const itemEq = parser.eq ?? ((x, y) => x === y)
534+
return itemEq(value, b[index])
535+
})
536+
}
537+
})
538+
}
539+
468540
type inferSingleParserType<Parser> = Parser extends ParserBuilder<
469541
infer Value
470542
> & {

0 commit comments

Comments
 (0)