Creates links type-safely.
This package distributed through a package registry called the JSR.
NPM:
npx jsr add @kokomi/link-generator
PNPM:
pnpm dlx jsr add @kokomi/link-generator
Deno:
deno add @kokomi/link-generator
Yarn:
yarn dlx jsr add @kokomi/link-generator
Bun:
bunx jsr add @kokomi/link-generator
-
Define a route configuration object:
import { link_generator, type RouteConfig } from "@kokomi/link-generator"; const route_config = { home: { path: "/", }, users: { path: "/users", children: { user: { path: "/:id", }, }, }, } as const satisfies RouteConfig;
-
Create a generator:
const link = link_generator(route_config);
-
Generate links:
link("home"); // => '/' link("users"); // => '/users' link("users/user", { id: "alice" }); // => '/users/alice'
The link function accepts a list of objects as query parameter generators,
starting from the second argument onward. In other words, the types of the
arguments from the second position onward in the link
function are any number
of Partial<Query>
types.
Moreover, if the object for setting query parameters contains an empty string or
an undefined
, the link
function will not generate that query.
-
Create a generator:
const route_config = { products: { path: "/products?color", }, } as const satisfies RouteConfig; const link = link_generator(route_config);
-
Generate links:
link("products", undefined, { color: "red" }, { color: "blue" }); // => '/products?color=red&color=blue' link("products", undefined, { color: "" }, { color: undefined }); // => /products
The link_generator
function also accepts an optional options object as its
second argument.
A boolean that specifies whether encoded query string should be appended to the
final link. This option is especially useful when combined with the transform
option to ensure that custom paths are preserved or enhanced with query strings
as needed. Default is true
.
const route_config = {
products: {
path: "/products?size",
},
};
const link = link_generator(route_config, { should_append_query: false });
link("products", undefined, { size: "sm" }); // => /products (no size query string!)
The transform
option allows you to intercept and customize the generated path
before it is finalized. It accepts a callback function that receives a
RouteContext
object containing detailed information such as the route ID,
current path, params, and query values.
The function should return either:
-
a
string
representing the custom path to use, or -
undefined
to indicate that the generator should fall back to the defaultctx.path
.
This makes it easy to override only specific cases while preserving the default behavior for others.
If should_append_query
is true
, then any query string will still be appended
to the returned string (whether from transform
or fallback). To prevent this,
you can set should_append_query: false
.
This option is useful when you want to customize routing logic, apply conditional rewrites, or insert static shortcuts programmatically.
const route_config = {
products: {
path: "/products?size",
},
};
const link = link_generator(route_config, {
transform: (ctx) => {
const { id, path, params, query } = ctx;
if (query.size) {
return "/custom";
}
},
should_append_query: false,
});
link("products"); // => /products
link("products", { size: "sm" }); // => /custom
If you pass a route ID that is not defined in your route configuration, the
link
function will throw an error at runtime. If you pass an empty string as
the first argument, it will throw an error with the message:
Invalid route id: EMPTY_STRING
.
const route_config = {
home: { path: "/" },
} as const satisfies RouteConfig;
const link = link_generator(route_config);
link("nonexistent"); // ❌ Throws: Error: Invalid route id: nonexistent
link(""); // ❌ Throws: Error: Invalid route id: EMPTY_STRING
The type of values for path and query parameters is string|number|boolean
by
default. While this is sufficient in most cases, this type can be made more
strict by defining a constraint area. This is a special string that can be
included in the path, like <Constraint>
. Conditions can be defined within
opening (<
) and closing (>
) angle brackets. In this field, the following
three type constraints can be placed on path and query parameters:
-
String Type
You can narrow down the id to a string type by defining a condition field with a parameter name followed by the string
string
, as in/:id<string>
. -
Number Type
You can narrow down the id to a number type by defining a condition field with a parameter name followed by the string
number
, as in/:id<number>
. -
Boolean Type
You can narrow down the id to a boolean type by defining a condition field with a parameter name followed by the string
boolean
, as in/:id<boolean>
. -
Union Type
If you want to be strict and require that params and query only accept certain values other than string, number, and boolean, use the
<(Type1|Type2)>
syntax.The type of each segment of a union type defaults to its string literal type, but you can manually cast strings that can be converted to a
number
, such as 1, or toboolean
, such astrue
orfalse
, or strings that represent types such as string, number, or boolean. To do this, simply prepend*
to the string you want to cast, for example*123
,*true
, or*string
.
-
Create a generator:
const route_config = { user: { path: "/users/:id<string>", }, post: { path: "/post/:id<number>", }, category: { path: "/categories/:id<(a|*10|*false)>", }, news: { path: "/news?archived<boolean>", }, image: { path: "/image?width<(auto|*number)>", }, } as const satisfies RouteConfig; const link = link_generator(route_config);
-
Generate links:
link("user", { id: "alice" }); // Param type: { id: string } link("post", { id: 1 }); // Param type: { id: number } link("category", { id: "a" }); // Param type: { id: 'a' | 10 | false } link("news", undefined, { archived: true }); // Query type: { archived: boolean } link("image", undefined, { width: "auto" }); // Query type: { width: 'auto' | number }
If the link you want to generate contains a protocol, special type inference
needs to be done, so write the protocol and domain like this, with the protocol
ending in ://
and no /
before the domain:
const route_config = {
external: {
path: "https://",
children: {
youtube: {
path: "youtube.com",
children: {
video: {
path: "/watch?v",
},
},
},
},
},
} as const satisfies RouteConfig;
const link = link_generator(route_config);
link("external/youtube/video", undefined, { v: "123" });
// => 'https://youtube.com/watch?v=123'
In this library, you can obtain detailed type information for each route using
the ExtractRouteData
type.
In addition, the FlatRoutes
type is provided to flatten your nested route
configuration into an object where each key represents a unique route ID and the
value is the corresponding path.
For example, consider the following route configuration:
import type { FlatRoutes, RouteConfig } from "@kokomi/link-generator";
const route_config = {
users: {
path: "/users",
children: {
user: {
path: "/:id",
},
},
},
} as const satisfies RouteConfig;
type Flattened = FlatRoutes<typeof route_config>;
The Flattened
type then looks like this:
{
users: "/users",
"users/user": "/users/:id"
}
This flattening mechanism makes it easier to manage nested routes by ensuring the uniqueness of route IDs and combining the nested paths in a predictable manner.
Furthermore, the ExtractRouteData
type leverages these flattened routes to
extract additional type information such as parameters and query types. For
example:
const route_config = {
user: {
path: "/users/:id",
},
news: {
path: "/news?archived<boolean>",
},
} as const satisfies RouteConfig;
type RouteData = ExtractRouteData<FlatRoutes<typeof route_config>>;
// {
// user: {
// path: "/users/:id";
// params: Record<"id", DefaultParamValue>;
// query: never;
// };
// news: {
// path: "/news";
// params: never;
// query: Partial<Record<"archived", boolean>>;
// };
// }
Links are fragile, so calling them by unique route ids is essential instead of hard-coding them. To ensure the uniqueness of route ids while creating them efficiently, we use TypeScript's type checking with objects. Defining properties with the same name at the same level of an object will cause a type error, ensuring no overlapping route ids. Additionally, child route ids can be made unique by prefixing them with the parent route id.
const obj = {
route1: {},
// Type error! An object literal cannot have multiple properties with the same name.
route1: {},
};
By leveraging this, we can be confident that route ids do not overlap within the same level of the object. Moreover, the uniqueness of child route ids can be achieved by prefixing them with the parent route id. If the parent route id is unique, the child route ids will also be unique by necessity.
const obj = {
parent1: {
children: {
child1: {},
},
},
parent2: {
children: {
child1: {},
},
},
};
The generated route ids would be:
- parent1
- parent1/child1
- parent2
- parent2/child1
This approach enables flexible route ID creation while ensuring uniqueness across a wide namespace.
This project was inspired by nanostores/router.
MIT