From bcd4e36acd720ce147defa5bd45db14cc5deff47 Mon Sep 17 00:00:00 2001
From: Francois Best <github@francoisbest.com>
Date: Wed, 26 Jun 2024 09:41:12 +0200
Subject: [PATCH 1/3] feat: Add parser type inference helpers

Closes #571.
---
 README.md                                | 35 +++++++++++++++++
 packages/docs/content/docs/utilities.mdx | 35 +++++++++++++++++
 packages/nuqs/src/parsers.ts             | 49 ++++++++++++++++++++++++
 3 files changed, 119 insertions(+)

diff --git a/README.md b/README.md
index ba3abd89a..5a6bbe1ba 100644
--- a/README.md
+++ b/README.md
@@ -684,6 +684,41 @@ 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`, you can use the
+`inferParserRecordType` helper:
+
+```ts
+import {
+  parseAsBoolean,
+  parseAsInteger,
+  type inferParserRecordType
+} from 'nuqs' // or 'nuqs/server'
+
+const parsers = {
+  a: parseAsInteger,
+  b: parseAsBoolean.withDefault(false)
+}
+
+inferParserRecordType<typeof parsers>
+// { 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..ad9421ed9 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<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)), you can use the
+`inferParserRecordType` helper:
+
+```ts
+import {
+  parseAsBoolean,
+  parseAsInteger,
+  type inferParserRecordType
+} from 'nuqs' // or 'nuqs/server'
+
+const parsers = {
+  a: parseAsInteger,
+  b: parseAsBoolean.withDefault(false)
+}
+
+inferParserRecordType<typeof parsers>
+// { a: number | null, b: boolean }
+```
diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts
index 67e51af14..e22aeccc8 100644
--- a/packages/nuqs/src/parsers.ts
+++ b/packages/nuqs/src/parsers.ts
@@ -404,3 +404,52 @@ export function parseAsArrayOf<ItemType>(
     }
   })
 }
+
+/**
+ * Type helper to extract the underlying returned data type of a parser.
+ *
+ * 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
+ * ```
+ */
+export type inferParserType<Parser> = Parser extends ParserBuilder<
+  infer Type
+> & {
+  defaultValue: infer Type
+}
+  ? Type
+  : Parser extends ParserBuilder<infer Type>
+    ? Type | null
+    : never
+
+/**
+ * Type helper to extract the underlying returned data type of an object
+ * describing multiple parsers and their associated keys.
+ *
+ * Usage:
+ *
+ * ```ts
+ * import { type inferParserRecordType } from 'nuqs' // or 'nuqs/server'
+ *
+ * const parsers = {
+ *  a: parseAsInteger,
+ *  b: parseAsBoolean.withDefault(false)
+ * }
+ *
+ * inferParserRecordType<typeof parsers>
+ * // { a: number | null, b: boolean }
+ * ```
+ */
+export type inferParserRecordType<
+  Map extends Record<string, ParserBuilder<any>>
+> = {
+  [Key in keyof Map]: inferParserType<Map[Key]>
+}

From 95b8271e4b69cebd55a3262eacbc1802521795ec Mon Sep 17 00:00:00 2001
From: Francois Best <github@francoisbest.com>
Date: Fri, 30 Aug 2024 21:45:20 +0200
Subject: [PATCH 2/3] test: Add type testing for inference helpers

---
 packages/nuqs/package.json                |  1 +
 packages/nuqs/src/tests/parsers.test-d.ts | 23 ++++++++++++++++++++++-
 pnpm-lock.yaml                            |  8 ++++++++
 3 files changed, 31 insertions(+), 1 deletion(-)

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/tests/parsers.test-d.ts b/packages/nuqs/src/tests/parsers.test-d.ts
index 757376ef2..f8a84c562 100644
--- a/packages/nuqs/src/tests/parsers.test-d.ts
+++ b/packages/nuqs/src/tests/parsers.test-d.ts
@@ -1,6 +1,12 @@
 import React from 'react'
+import { assert, type Equals } from 'tsafe'
 import { expectError, expectType } from 'tsd'
-import { parseAsString } from '../../dist'
+import {
+  parseAsInteger,
+  parseAsString,
+  type inferParserRecordType,
+  type inferParserType
+} from '../../dist'
 
 {
   const p = parseAsString
@@ -75,3 +81,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<
+    inferParserRecordType<typeof parsers>,
+    { 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

From c2b490885e8b5d351e63e39761ac499a6c6b05bb Mon Sep 17 00:00:00 2001
From: Francois Best <github@francoisbest.com>
Date: Fri, 30 Aug 2024 21:53:06 +0200
Subject: [PATCH 3/3] feat: Provide a single inference helper that handles both
 singular parser & records

---
 README.md                                 | 12 ++----
 packages/docs/content/docs/utilities.mdx  |  8 ++--
 packages/nuqs/src/parsers.ts              | 46 +++++++++++------------
 packages/nuqs/src/tests/parsers.test-d.ts |  9 +----
 4 files changed, 31 insertions(+), 44 deletions(-)

diff --git a/README.md b/README.md
index 5a6bbe1ba..12c5709c2 100644
--- a/README.md
+++ b/README.md
@@ -700,22 +700,18 @@ inferParserType<typeof intNonNull> // number
 ```
 
 For an object describing parsers (that you'd pass to `createSearchParamsCache`
-or to `useQueryStates`, you can use the
-`inferParserRecordType` helper:
+or to `useQueryStates`, `inferParserType` will
+return the type of the object with the parsers replaced by their inferred types:
 
 ```ts
-import {
-  parseAsBoolean,
-  parseAsInteger,
-  type inferParserRecordType
-} from 'nuqs' // or 'nuqs/server'
+import { parseAsBoolean, parseAsInteger, type inferParserType } from 'nuqs' // or 'nuqs/server'
 
 const parsers = {
   a: parseAsInteger,
   b: parseAsBoolean.withDefault(false)
 }
 
-inferParserRecordType<typeof parsers>
+inferParserType<typeof parsers>
 // { a: number | null, b: boolean }
 ```
 
diff --git a/packages/docs/content/docs/utilities.mdx b/packages/docs/content/docs/utilities.mdx
index ad9421ed9..eec7f560a 100644
--- a/packages/docs/content/docs/utilities.mdx
+++ b/packages/docs/content/docs/utilities.mdx
@@ -78,14 +78,14 @@ inferParserType<typeof intNonNull> // number
 ```
 
 For an object describing parsers (that you'd pass to [`createSearchParamsCache`](./server-side)
-or to [`useQueryStates`](./batching#usequerystates)), you can use the
-`inferParserRecordType` helper:
+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 inferParserRecordType
+  type inferParserType
 } from 'nuqs' // or 'nuqs/server'
 
 const parsers = {
@@ -93,6 +93,6 @@ const parsers = {
   b: parseAsBoolean.withDefault(false)
 }
 
-inferParserRecordType<typeof parsers>
+inferParserType<typeof parsers>
 // { a: number | null, b: boolean }
 ```
diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts
index e22aeccc8..71176f0d5 100644
--- a/packages/nuqs/src/parsers.ts
+++ b/packages/nuqs/src/parsers.ts
@@ -405,22 +405,7 @@ export function parseAsArrayOf<ItemType>(
   })
 }
 
-/**
- * Type helper to extract the underlying returned data type of a parser.
- *
- * 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
- * ```
- */
-export type inferParserType<Parser> = Parser extends ParserBuilder<
+type inferSingleParserType<Parser> = Parser extends ParserBuilder<
   infer Type
 > & {
   defaultValue: infer Type
@@ -430,26 +415,37 @@ export type inferParserType<Parser> = Parser extends ParserBuilder<
     ? 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 an object
- * describing multiple parsers and their associated keys.
+ * 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 inferParserRecordType } from 'nuqs' // or 'nuqs/server'
+ * 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)
  * }
  *
- * inferParserRecordType<typeof parsers>
+ * inferParserType<typeof parsers>
  * // { a: number | null, b: boolean }
  * ```
  */
-export type inferParserRecordType<
-  Map extends Record<string, ParserBuilder<any>>
-> = {
-  [Key in keyof Map]: inferParserType<Map[Key]>
-}
+export type inferParserType<Input> =
+  Input extends ParserBuilder<any>
+    ? inferSingleParserType<Input>
+    : Input extends Record<string, ParserBuilder<any>>
+      ? inferParserRecordType<Input>
+      : never
diff --git a/packages/nuqs/src/tests/parsers.test-d.ts b/packages/nuqs/src/tests/parsers.test-d.ts
index f8a84c562..2ba2a16fb 100644
--- a/packages/nuqs/src/tests/parsers.test-d.ts
+++ b/packages/nuqs/src/tests/parsers.test-d.ts
@@ -1,12 +1,7 @@
 import React from 'react'
 import { assert, type Equals } from 'tsafe'
 import { expectError, expectType } from 'tsd'
-import {
-  parseAsInteger,
-  parseAsString,
-  type inferParserRecordType,
-  type inferParserType
-} from '../../dist'
+import { parseAsInteger, parseAsString, type inferParserType } from '../../dist'
 
 {
   const p = parseAsString
@@ -92,7 +87,7 @@ const parsers = {
 }
 assert<
   Equals<
-    inferParserRecordType<typeof parsers>,
+    inferParserType<typeof parsers>,
     { str: string | null; int: number | null }
   >
 >()