Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/nuqs/src/parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
parseAsString,
parseAsStringEnum,
parseAsStringLiteral,
parseAsTimestamp
parseAsTimestamp,
parseAsTuple
} from './parsers'
import {
isParserBijective,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
})
})
72 changes: 72 additions & 0 deletions packages/nuqs/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,78 @@ export function parseAsArrayOf<ItemType>(
})
}

type ParserTuple<T extends readonly unknown[]> = {
[K in keyof T]: ParserBuilder<T[K]>
} & { 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<T extends any[]>(
itemParsers: ParserTuple<T>,
separator = ','
): ParserBuilder<T> {
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<T>({
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> = Parser extends ParserBuilder<
infer Value
> & {
Expand Down
Loading