diff --git a/packages/next-typed-href/src/nuqs.test.ts b/packages/next-typed-href/src/nuqs.test.ts index 0457f94..e208ea4 100644 --- a/packages/next-typed-href/src/nuqs.test.ts +++ b/packages/next-typed-href/src/nuqs.test.ts @@ -214,3 +214,76 @@ describe("requiredSearchParams option", () => { $hrefReq({ route: "/search", searchParams: { page: 2 } }); }); }); + +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: $hrefBoth } = defineTypedHrefWithNuqs()({ + requiredSearchParams: true, + nonNullableSearchParams: true, + })({ + "/search": { q: parseAsString, page: parseAsInteger.withDefault(1) }, + }); + + test("accepts required field only (withDefault field omitted)", () => { + expect($hrefBoth({ route: "/search", searchParams: { q: "hello" } })).toBe("/search?q=hello"); + }); + + test("accepts both fields", () => { + expect($hrefBoth({ route: "/search", searchParams: { q: "hello", page: 2 } })).toBe( + "/search?q=hello&page=2", + ); + }); + + test("non-nuqs routes still have optional searchParams", () => { + 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 + $hrefBoth({ route: "/search" }); + }); + + test("rejects missing required field (type error)", () => { + // @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 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 a688f0e..92efcbc 100644 --- a/packages/next-typed-href/src/nuqs.ts +++ b/packages/next-typed-href/src/nuqs.ts @@ -13,19 +13,32 @@ type ParserValues> = { [K in keyof Parsers]?: inferParserType; }; -// null が含まれる(= withDefault なし)フィールドは required、それ以外(withDefault あり)は 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] - >; + [K in keyof Parsers as RequiredFields extends true + ? null extends inferParserType + ? never + : K + : K]?: FieldType; }; export type DefineTypedHrefWithNuqsOptions = { requiredSearchParams?: boolean; + nonNullableSearchParams?: boolean; }; type RouteHasParams< @@ -40,8 +53,14 @@ type SearchParamsFor< > = NuqsMap[T] extends Record ? Options["requiredSearchParams"] extends true - ? RequiredParserValues - : ParserValues + ? ParserValuesWithOptions< + NuqsMap[T], + true, + Options["nonNullableSearchParams"] extends true ? true : false + > + : Options["nonNullableSearchParams"] extends true + ? ParserValuesWithOptions + : ParserValues : ConstructorParameters[0]; type RouteHasNuqsParsers> = @@ -88,8 +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. + * 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()()({ @@ -99,18 +119,37 @@ 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 (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 } }) // OK (null clears the param) + * $href({ route: "/search" }) // Type error: searchParams is required + * + * @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,