diff --git a/README.md b/README.md index ba3abd89a..12c5709c2 100644 --- a/README.md +++ b/README.md @@ -684,6 +684,37 @@ serialize(url, { foo: 'bar' }) // https://example.com/path?baz=qux&foo=bar serialize('?remove=me', { foo: 'bar', remove: null }) // ?foo=bar ``` +## Parser type inference + +To access the underlying type returned by a parser, you can use the +`inferParserType` type helper: + +```ts +import { parseAsInteger, type inferParserType } from 'nuqs' // or 'nuqs/server' + +const intNullable = parseAsInteger +const intNonNull = parseAsInteger.withDefault(0) + +inferParserType // number | null +inferParserType // number +``` + +For an object describing parsers (that you'd pass to `createSearchParamsCache` +or to `useQueryStates`, `inferParserType` will +return the type of the object with the parsers replaced by their inferred types: + +```ts +import { parseAsBoolean, parseAsInteger, type inferParserType } from 'nuqs' // or 'nuqs/server' + +const parsers = { + a: parseAsInteger, + b: parseAsBoolean.withDefault(false) +} + +inferParserType +// { a: number | null, b: boolean } +``` + ## Testing Currently, the best way to test the behaviour of your components using diff --git a/packages/docs/content/docs/utilities.mdx b/packages/docs/content/docs/utilities.mdx index a5be7beaf..eec7f560a 100644 --- a/packages/docs/content/docs/utilities.mdx +++ b/packages/docs/content/docs/utilities.mdx @@ -61,3 +61,38 @@ serialize(url, { foo: 'bar' }) // https://example.com/path?baz=qux&foo=bar // Passing null removes existing values serialize('?remove=me', { foo: 'bar', remove: null }) // ?foo=bar ``` + +## Parser type inference + +To access the underlying type returned by a parser, you can use the +`inferParserType` type helper: + +```ts +import { parseAsInteger, type inferParserType } from 'nuqs' // or 'nuqs/server' + +const intNullable = parseAsInteger +const intNonNull = parseAsInteger.withDefault(0) + +inferParserType // number | null +inferParserType // number +``` + +For an object describing parsers (that you'd pass to [`createSearchParamsCache`](./server-side) +or to [`useQueryStates`](./batching#usequerystates)), `inferParserType` will +return the type of the object with the parsers replaced by their inferred types: + +```ts +import { + parseAsBoolean, + parseAsInteger, + type inferParserType +} from 'nuqs' // or 'nuqs/server' + +const parsers = { + a: parseAsInteger, + b: parseAsBoolean.withDefault(false) +} + +inferParserType +// { a: number | null, b: boolean } +``` diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index bf3012efc..741d20661 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -79,6 +79,7 @@ "react": "rc", "react-dom": "rc", "size-limit": "^11.1.2", + "tsafe": "^1.7.2", "tsd": "^0.30.7", "tsup": "^8.0.2", "typescript": "^5.4.5", diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 67e51af14..71176f0d5 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -404,3 +404,48 @@ export function parseAsArrayOf( } }) } + +type inferSingleParserType = Parser extends ParserBuilder< + infer Type +> & { + defaultValue: infer Type +} + ? Type + : Parser extends ParserBuilder + ? Type | null + : never + +type inferParserRecordType>> = { + [Key in keyof Map]: inferSingleParserType +} + +/** + * Type helper to extract the underlying returned data type of a parser + * or of an object describing multiple parsers and their associated keys. + * + * Usage: + * + * ```ts + * import { type inferParserType } from 'nuqs' // or 'nuqs/server' + * + * const intNullable = parseAsInteger + * const intNonNull = parseAsInteger.withDefault(0) + * + * inferParserType // number | null + * inferParserType // number + * + * const parsers = { + * a: parseAsInteger, + * b: parseAsBoolean.withDefault(false) + * } + * + * inferParserType + * // { a: number | null, b: boolean } + * ``` + */ +export type inferParserType = + Input extends ParserBuilder + ? inferSingleParserType + : Input extends Record> + ? inferParserRecordType + : never diff --git a/packages/nuqs/src/tests/parsers.test-d.ts b/packages/nuqs/src/tests/parsers.test-d.ts index 757376ef2..2ba2a16fb 100644 --- a/packages/nuqs/src/tests/parsers.test-d.ts +++ b/packages/nuqs/src/tests/parsers.test-d.ts @@ -1,6 +1,7 @@ import React from 'react' +import { assert, type Equals } from 'tsafe' import { expectError, expectType } from 'tsd' -import { parseAsString } from '../../dist' +import { parseAsInteger, parseAsString, type inferParserType } from '../../dist' { const p = parseAsString @@ -75,3 +76,18 @@ import { parseAsString } from '../../dist' }) }) } + +// Type inference +assert, string | null>>() +const withDefault = parseAsString.withDefault('') +assert, string>>() +const parsers = { + str: parseAsString, + int: parseAsInteger +} +assert< + Equals< + inferParserType, + { str: string | null; int: number | null } + > +>() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44e7a898c..c7b8bbdcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: size-limit: specifier: ^11.1.2 version: 11.1.2 + tsafe: + specifier: ^1.7.2 + version: 1.7.2 tsd: specifier: ^0.30.7 version: 0.30.7 @@ -5438,6 +5441,9 @@ packages: peerDependencies: typescript: '>=2.7' + tsafe@1.7.2: + resolution: {integrity: sha512-dAPfQLhCfCRre5qs+Z5Q2a7s2CV7RxffZUmvj7puGaePYjECzWREJFd3w4XSFe/T5tbxgowfItA/JSSZ6Ma3dA==} + tsd@0.30.7: resolution: {integrity: sha512-oTiJ28D6B/KXoU3ww/Eji+xqHJojiuPVMwA12g4KYX1O72N93Nb6P3P3h2OAhhf92Xl8NIhb/xFmBZd5zw/xUw==} engines: {node: '>=14.16'} @@ -11819,6 +11825,8 @@ snapshots: typescript: 5.4.5 yn: 3.1.1 + tsafe@1.7.2: {} + tsd@0.30.7: dependencies: '@tsd/typescript': 5.3.3