Skip to content

Commit

Permalink
feat: Add parser type inference helpers (#578)
Browse files Browse the repository at this point in the history
* feat: Add parser type inference helpers

Closes #571.

* test: Add type testing for inference helpers

* feat: Provide a single inference helper that handles both singular parser & records
  • Loading branch information
franky47 authored Aug 30, 2024
1 parent a00215f commit e2c88a6
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 1 deletion.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof intNullable> // number | null
inferParserType<typeof intNonNull> // 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<typeof parsers>
// { a: number | null, b: boolean }
```

## Testing

Currently, the best way to test the behaviour of your components using
Expand Down
35 changes: 35 additions & 0 deletions packages/docs/content/docs/utilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof intNullable> // number | null
inferParserType<typeof intNonNull> // 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<typeof parsers>
// { a: number | null, b: boolean }
```
1 change: 1 addition & 0 deletions packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions packages/nuqs/src/parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,48 @@ export function parseAsArrayOf<ItemType>(
}
})
}

type inferSingleParserType<Parser> = Parser extends ParserBuilder<
infer Type
> & {
defaultValue: infer Type
}
? Type
: Parser extends ParserBuilder<infer Type>
? Type | null
: never

type inferParserRecordType<Map extends Record<string, ParserBuilder<any>>> = {
[Key in keyof Map]: inferSingleParserType<Map[Key]>
}

/**
* 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<typeof intNullable> // number | null
* inferParserType<typeof intNonNull> // number
*
* const parsers = {
* a: parseAsInteger,
* b: parseAsBoolean.withDefault(false)
* }
*
* inferParserType<typeof parsers>
* // { a: number | null, b: boolean }
* ```
*/
export type inferParserType<Input> =
Input extends ParserBuilder<any>
? inferSingleParserType<Input>
: Input extends Record<string, ParserBuilder<any>>
? inferParserRecordType<Input>
: never
18 changes: 17 additions & 1 deletion packages/nuqs/src/tests/parsers.test-d.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -75,3 +76,18 @@ import { parseAsString } from '../../dist'
})
})
}

// Type inference
assert<Equals<inferParserType<typeof parseAsString>, string | null>>()
const withDefault = parseAsString.withDefault('')
assert<Equals<inferParserType<typeof withDefault>, string>>()
const parsers = {
str: parseAsString,
int: parseAsInteger
}
assert<
Equals<
inferParserType<typeof parsers>,
{ str: string | null; int: number | null }
>
>()
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit e2c88a6

Please sign in to comment.