Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions packages/next-typed-href/src/nuqs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Routes, RouteParamsMap>()({
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<Routes, RouteParamsMap>()({
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 } });
});
});
77 changes: 58 additions & 19 deletions packages/next-typed-href/src/nuqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,32 @@ type ParserValues<Parsers extends Record<string, AnyParserBuilder>> = {
[K in keyof Parsers]?: inferParserType<Parsers[K]>;
};

// null が含まれる(= withDefault なし)フィールドは required、それ以外(withDefault あり)は optional
type RequiredParserValues<Parsers extends Record<string, AnyParserBuilder>> = {
[K in keyof Parsers as null extends inferParserType<Parsers[K]> ? K : never]: inferParserType<
Parsers[K]
>;
type FieldType<P extends AnyParserBuilder, NonNullable extends boolean> = NonNullable extends true
? globalThis.NonNullable<inferParserType<P>>
: inferParserType<P>;

// withDefault なし(nullable)かつ RequiredFields=true のフィールドは required、それ以外は optional
type ParserValuesWithOptions<
Parsers extends Record<string, AnyParserBuilder>,
RequiredFields extends boolean,
NonNullableFields extends boolean,
> = {
[K in keyof Parsers as RequiredFields extends true
? null extends inferParserType<Parsers[K]>
? K
: never
: never]: FieldType<Parsers[K], NonNullableFields>;
} & {
[K in keyof Parsers as null extends inferParserType<Parsers[K]> ? never : K]?: inferParserType<
Parsers[K]
>;
[K in keyof Parsers as RequiredFields extends true
? null extends inferParserType<Parsers[K]>
? never
: K
: K]?: FieldType<Parsers[K], NonNullableFields>;
};

export type DefineTypedHrefWithNuqsOptions = {
requiredSearchParams?: boolean;
nonNullableSearchParams?: boolean;
};

type RouteHasParams<
Expand All @@ -40,8 +53,14 @@ type SearchParamsFor<
> =
NuqsMap[T] extends Record<string, AnyParserBuilder>
? Options["requiredSearchParams"] extends true
? RequiredParserValues<NuqsMap[T]>
: ParserValues<NuqsMap[T]>
? ParserValuesWithOptions<
NuqsMap[T],
true,
Options["nonNullableSearchParams"] extends true ? true : false
>
: Options["nonNullableSearchParams"] extends true
? ParserValuesWithOptions<NuqsMap[T], false, true>
: ParserValues<NuqsMap[T]>
: ConstructorParameters<typeof URLSearchParams>[0];

type RouteHasNuqsParsers<T extends string, NuqsMap extends NuqsParsersMap<string>> =
Expand Down Expand Up @@ -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<Routes, RouteParamsMap>()()({
Expand All @@ -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<Routes, RouteParamsMap>()({ 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<Routes, RouteParamsMap>()({ 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<Routes, RouteParamsMap>()({
* 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,
Expand Down
Loading