From 7d8e82b183ca512ec81bf6fef54e3f4b5ac31eff Mon Sep 17 00:00:00 2001 From: Amon Sawamura Date: Tue, 28 Apr 2026 18:19:48 +0900 Subject: [PATCH 1/3] chore: stub for strict search params mode From 3b3c68c8d06c431fcef6898c11681c6471f1cc42 Mon Sep 17 00:00:00 2001 From: Amon Sawamura Date: Tue, 28 Apr 2026 18:21:16 +0900 Subject: [PATCH 2/3] feat(next-typed-href): add requiredSearchParams: "strict" mode (disallow null) Co-Authored-By: Claude Sonnet 4.6 --- packages/next-typed-href/src/nuqs.test.ts | 39 +++++++++++++++++ packages/next-typed-href/src/nuqs.ts | 51 +++++++++++++++++------ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/packages/next-typed-href/src/nuqs.test.ts b/packages/next-typed-href/src/nuqs.test.ts index 0457f94..c3425d0 100644 --- a/packages/next-typed-href/src/nuqs.test.ts +++ b/packages/next-typed-href/src/nuqs.test.ts @@ -214,3 +214,42 @@ describe("requiredSearchParams option", () => { $hrefReq({ route: "/search", searchParams: { page: 2 } }); }); }); + +describe('requiredSearchParams: "strict" option', () => { + // q: no withDefault → required + non-nullable, page: withDefault → optional + const { $href: $hrefStrict } = defineTypedHrefWithNuqs()({ + requiredSearchParams: "strict", + })({ + "/search": { q: parseAsString, page: parseAsInteger.withDefault(1) }, + }); + + test("accepts required field only (withDefault field omitted)", () => { + expect($hrefStrict({ route: "/search", searchParams: { q: "hello" } })).toBe("/search?q=hello"); + }); + + test("accepts both fields", () => { + expect($hrefStrict({ route: "/search", searchParams: { q: "hello", page: 2 } })).toBe( + "/search?q=hello&page=2", + ); + }); + + test("non-nuqs routes still have optional searchParams", () => { + expect($hrefStrict({ route: "/posts" })).toBe("/posts"); + expect($hrefStrict({ route: "/posts", searchParams: { page: "1" } })).toBe("/posts?page=1"); + }); + + test("rejects missing searchParams object (type error)", () => { + // @ts-expect-error: searchParams is required + $hrefStrict({ route: "/search" }); + }); + + test("rejects missing required field (type error)", () => { + // @ts-expect-error: q is required + $hrefStrict({ route: "/search", searchParams: { page: 2 } }); + }); + + test("rejects null for required field (type error)", () => { + // @ts-expect-error: null is not allowed in strict mode + $hrefStrict({ route: "/search", searchParams: { q: null } }); + }); +}); diff --git a/packages/next-typed-href/src/nuqs.ts b/packages/next-typed-href/src/nuqs.ts index a688f0e..c5f48d7 100644 --- a/packages/next-typed-href/src/nuqs.ts +++ b/packages/next-typed-href/src/nuqs.ts @@ -13,7 +13,7 @@ type ParserValues> = { [K in keyof Parsers]?: inferParserType; }; -// null が含まれる(= withDefault なし)フィールドは required、それ以外(withDefault あり)は optional +// withDefault なし(nullable)→ required、withDefault あり(non-nullable)→ optional type RequiredParserValues> = { [K in keyof Parsers as null extends inferParserType ? K : never]: inferParserType< Parsers[K] @@ -24,8 +24,19 @@ type RequiredParserValues> = { >; }; +// withDefault なし → required かつ NonNullable、withDefault あり → optional +type StrictParserValues> = { + [K in keyof Parsers as null extends inferParserType ? K : never]: NonNullable< + inferParserType + >; +} & { + [K in keyof Parsers as null extends inferParserType ? never : K]?: inferParserType< + Parsers[K] + >; +}; + export type DefineTypedHrefWithNuqsOptions = { - requiredSearchParams?: boolean; + requiredSearchParams?: boolean | "strict"; }; type RouteHasParams< @@ -39,9 +50,11 @@ type SearchParamsFor< Options extends DefineTypedHrefWithNuqsOptions, > = NuqsMap[T] extends Record - ? Options["requiredSearchParams"] extends true - ? RequiredParserValues - : ParserValues + ? Options["requiredSearchParams"] extends "strict" + ? StrictParserValues + : Options["requiredSearchParams"] extends true | "strict" + ? RequiredParserValues + : ParserValues : ConstructorParameters[0]; type RouteHasNuqsParsers> = @@ -51,7 +64,7 @@ type SearchParamsOptions< T extends string, NuqsMap extends NuqsParsersMap, Options extends DefineTypedHrefWithNuqsOptions, -> = Options["requiredSearchParams"] extends true +> = Options["requiredSearchParams"] extends true | "strict" ? RouteHasNuqsParsers extends true ? { searchParams: SearchParamsFor } : { searchParams?: SearchParamsFor } @@ -90,6 +103,7 @@ type InnerFn< * * Pass `{ requiredSearchParams: true }` in the second call to make `searchParams` * required on routes that have nuqs parsers defined. + * Pass `{ requiredSearchParams: "strict" }` to additionally disallow `null` values. * * @example * const { $href } = defineTypedHrefWithNuqs()()({ @@ -99,18 +113,29 @@ type InnerFn< * $href({ route: "/search", searchParams: { q: "hello", page: 2 } }) * // => "/search?q=hello&page=2" * - * @example requiredSearchParams + * @example requiredSearchParams: true * const { $href } = defineTypedHrefWithNuqs()({ requiredSearchParams: true })({ * "/search": { - * q: parseAsString, // required (no withDefault) - * page: parseAsInteger.withDefault(1), // optional (has withDefault) + * q: parseAsString, // required, null allowed + * page: parseAsInteger.withDefault(1), // optional + * }, + * }); + * + * $href({ route: "/search", searchParams: { q: "hello" } }) // OK + * $href({ route: "/search", searchParams: { q: null } }) // OK (null clears the param) + * $href({ route: "/search" }) // Type error: searchParams is required + * + * @example requiredSearchParams: "strict" + * const { $href } = defineTypedHrefWithNuqs()({ requiredSearchParams: "strict" })({ + * "/search": { + * q: parseAsString, // required, null not allowed + * page: parseAsInteger.withDefault(1), // optional * }, * }); * - * $href({ route: "/search", searchParams: { q: "hello" } }) // OK (page is optional) - * $href({ route: "/search", searchParams: { q: "hello", page: 2 } }) // OK - * $href({ route: "/search" }) // Type error: searchParams is required - * $href({ route: "/search", searchParams: { page: 2 } }) // Type error: q is required + * $href({ route: "/search", searchParams: { q: "hello" } }) // OK + * $href({ route: "/search", searchParams: { q: null } }) // Type error: null not allowed + * $href({ route: "/search" }) // Type error: searchParams is required */ export function defineTypedHrefWithNuqs< Routes extends string, From 1c09e3d53851c2c2eca85866ad9bea2423f9a9dc Mon Sep 17 00:00:00 2001 From: Amon Sawamura Date: Tue, 28 Apr 2026 18:27:52 +0900 Subject: [PATCH 3/3] feat: add nonNullableSearchParams option as independent axis from requiredSearchParams Co-Authored-By: Claude Sonnet 4.6 --- packages/next-typed-href/src/nuqs.test.ts | 58 +++++++++++---- packages/next-typed-href/src/nuqs.ts | 88 +++++++++++++---------- 2 files changed, 97 insertions(+), 49 deletions(-) diff --git a/packages/next-typed-href/src/nuqs.test.ts b/packages/next-typed-href/src/nuqs.test.ts index c3425d0..e208ea4 100644 --- a/packages/next-typed-href/src/nuqs.test.ts +++ b/packages/next-typed-href/src/nuqs.test.ts @@ -215,41 +215,75 @@ describe("requiredSearchParams option", () => { }); }); -describe('requiredSearchParams: "strict" option', () => { +describe("nonNullableSearchParams option (independent)", () => { + // nonNullableSearchParams: true alone — searchParams is still optional, but null is disallowed + const { $href: $hrefNN } = defineTypedHrefWithNuqs()({ + nonNullableSearchParams: true, + })({ + "/search": { q: parseAsString, page: parseAsInteger.withDefault(1) }, + }); + + test("accepts string param", () => { + expect($hrefNN({ route: "/search", searchParams: { q: "hello" } })).toBe("/search?q=hello"); + }); + + test("accepts both fields", () => { + expect($hrefNN({ route: "/search", searchParams: { q: "hello", page: 2 } })).toBe( + "/search?q=hello&page=2", + ); + }); + + test("works without searchParams (still optional)", () => { + expect($hrefNN({ route: "/search" })).toBe("/search"); + }); + + test("non-nuqs routes still have optional searchParams", () => { + expect($hrefNN({ route: "/posts" })).toBe("/posts"); + expect($hrefNN({ route: "/posts", searchParams: { page: "1" } })).toBe("/posts?page=1"); + }); + + test("rejects null for nullable field (type error)", () => { + // @ts-expect-error: null is not allowed when nonNullableSearchParams: true + $hrefNN({ route: "/search", searchParams: { q: null } }); + }); +}); + +describe("nonNullableSearchParams + requiredSearchParams combined", () => { // q: no withDefault → required + non-nullable, page: withDefault → optional - const { $href: $hrefStrict } = defineTypedHrefWithNuqs()({ - requiredSearchParams: "strict", + const { $href: $hrefBoth } = defineTypedHrefWithNuqs()({ + requiredSearchParams: true, + nonNullableSearchParams: true, })({ "/search": { q: parseAsString, page: parseAsInteger.withDefault(1) }, }); test("accepts required field only (withDefault field omitted)", () => { - expect($hrefStrict({ route: "/search", searchParams: { q: "hello" } })).toBe("/search?q=hello"); + expect($hrefBoth({ route: "/search", searchParams: { q: "hello" } })).toBe("/search?q=hello"); }); test("accepts both fields", () => { - expect($hrefStrict({ route: "/search", searchParams: { q: "hello", page: 2 } })).toBe( + expect($hrefBoth({ route: "/search", searchParams: { q: "hello", page: 2 } })).toBe( "/search?q=hello&page=2", ); }); test("non-nuqs routes still have optional searchParams", () => { - expect($hrefStrict({ route: "/posts" })).toBe("/posts"); - expect($hrefStrict({ route: "/posts", searchParams: { page: "1" } })).toBe("/posts?page=1"); + expect($hrefBoth({ route: "/posts" })).toBe("/posts"); + expect($hrefBoth({ route: "/posts", searchParams: { page: "1" } })).toBe("/posts?page=1"); }); test("rejects missing searchParams object (type error)", () => { // @ts-expect-error: searchParams is required - $hrefStrict({ route: "/search" }); + $hrefBoth({ route: "/search" }); }); test("rejects missing required field (type error)", () => { - // @ts-expect-error: q is required - $hrefStrict({ route: "/search", searchParams: { page: 2 } }); + // @ts-expect-error: q has no withDefault, so it is required + $hrefBoth({ route: "/search", searchParams: { page: 2 } }); }); test("rejects null for required field (type error)", () => { - // @ts-expect-error: null is not allowed in strict mode - $hrefStrict({ route: "/search", searchParams: { q: null } }); + // @ts-expect-error: null is not allowed when nonNullableSearchParams: true + $hrefBoth({ route: "/search", searchParams: { q: null } }); }); }); diff --git a/packages/next-typed-href/src/nuqs.ts b/packages/next-typed-href/src/nuqs.ts index c5f48d7..92efcbc 100644 --- a/packages/next-typed-href/src/nuqs.ts +++ b/packages/next-typed-href/src/nuqs.ts @@ -13,30 +13,32 @@ type ParserValues> = { [K in keyof Parsers]?: inferParserType; }; -// withDefault なし(nullable)→ required、withDefault あり(non-nullable)→ optional -type RequiredParserValues> = { - [K in keyof Parsers as null extends inferParserType ? K : never]: inferParserType< - Parsers[K] - >; +type FieldType

= NonNullable extends true + ? globalThis.NonNullable> + : inferParserType

; + +// withDefault なし(nullable)かつ RequiredFields=true のフィールドは required、それ以外は optional +type ParserValuesWithOptions< + Parsers extends Record, + RequiredFields extends boolean, + NonNullableFields extends boolean, +> = { + [K in keyof Parsers as RequiredFields extends true + ? null extends inferParserType + ? K + : never + : never]: FieldType; } & { - [K in keyof Parsers as null extends inferParserType ? never : K]?: inferParserType< - Parsers[K] - >; -}; - -// withDefault なし → required かつ NonNullable、withDefault あり → optional -type StrictParserValues> = { - [K in keyof Parsers as null extends inferParserType ? K : never]: NonNullable< - inferParserType - >; -} & { - [K in keyof Parsers as null extends inferParserType ? never : K]?: inferParserType< - Parsers[K] - >; + [K in keyof Parsers as RequiredFields extends true + ? null extends inferParserType + ? never + : K + : K]?: FieldType; }; export type DefineTypedHrefWithNuqsOptions = { - requiredSearchParams?: boolean | "strict"; + requiredSearchParams?: boolean; + nonNullableSearchParams?: boolean; }; type RouteHasParams< @@ -50,10 +52,14 @@ type SearchParamsFor< Options extends DefineTypedHrefWithNuqsOptions, > = NuqsMap[T] extends Record - ? Options["requiredSearchParams"] extends "strict" - ? StrictParserValues - : Options["requiredSearchParams"] extends true | "strict" - ? RequiredParserValues + ? Options["requiredSearchParams"] extends true + ? ParserValuesWithOptions< + NuqsMap[T], + true, + Options["nonNullableSearchParams"] extends true ? true : false + > + : Options["nonNullableSearchParams"] extends true + ? ParserValuesWithOptions : ParserValues : ConstructorParameters[0]; @@ -64,7 +70,7 @@ type SearchParamsOptions< T extends string, NuqsMap extends NuqsParsersMap, Options extends DefineTypedHrefWithNuqsOptions, -> = Options["requiredSearchParams"] extends true | "strict" +> = Options["requiredSearchParams"] extends true ? RouteHasNuqsParsers extends true ? { searchParams: SearchParamsFor } : { searchParams?: SearchParamsFor } @@ -101,9 +107,9 @@ type InnerFn< * Routes that have nuqs parsers defined accept typed searchParams values. * Routes without parsers fall back to standard URLSearchParams input. * - * Pass `{ requiredSearchParams: true }` in the second call to make `searchParams` - * required on routes that have nuqs parsers defined. - * Pass `{ requiredSearchParams: "strict" }` to additionally disallow `null` values. + * Options (independent, can be combined): + * - `requiredSearchParams: true` — searchParams object required; fields without `.withDefault()` become required + * - `nonNullableSearchParams: true` — `null` disallowed for all fields (applies regardless of requiredSearchParams) * * @example * const { $href } = defineTypedHrefWithNuqs()()({ @@ -121,21 +127,29 @@ type InnerFn< * }, * }); * - * $href({ route: "/search", searchParams: { q: "hello" } }) // OK - * $href({ route: "/search", searchParams: { q: null } }) // OK (null clears the param) - * $href({ route: "/search" }) // Type error: searchParams is required + * $href({ route: "/search", searchParams: { q: "hello" } }) // OK + * $href({ route: "/search", searchParams: { q: null } }) // OK (null clears the param) + * $href({ route: "/search" }) // Type error: searchParams is required * - * @example requiredSearchParams: "strict" - * const { $href } = defineTypedHrefWithNuqs()({ requiredSearchParams: "strict" })({ - * "/search": { - * q: parseAsString, // required, null not allowed - * page: parseAsInteger.withDefault(1), // optional - * }, + * @example nonNullableSearchParams: true + * const { $href } = defineTypedHrefWithNuqs()({ nonNullableSearchParams: true })({ + * "/search": { q: parseAsString, page: parseAsInteger.withDefault(1) }, * }); * * $href({ route: "/search", searchParams: { q: "hello" } }) // OK * $href({ route: "/search", searchParams: { q: null } }) // Type error: null not allowed + * + * @example both options + * const { $href } = defineTypedHrefWithNuqs()({ + * requiredSearchParams: true, + * nonNullableSearchParams: true, + * })({ + * "/search": { q: parseAsString, page: parseAsInteger.withDefault(1) }, + * }); + * + * $href({ route: "/search", searchParams: { q: "hello" } }) // OK * $href({ route: "/search" }) // Type error: searchParams is required + * $href({ route: "/search", searchParams: { q: null } }) // Type error: null not allowed */ export function defineTypedHrefWithNuqs< Routes extends string,