routopia
v1.1.1
Published
Type-safe URL builder based on your route definitions.
Maintainers
Readme
routopia
A type-safe URL builder library based on your route definitions

✨ Features
- 🔒 Type-safe route definition and path construction
- 📦 Lightweight and no dependencies
- 🥰 Smooth development experience with type inference and autocompletion
- 👍 Supports use cases like Base URL and Catch-all Parameters
- 🧩 Strict URL type inference using template literal types
🚀 Getting Started
1. Install
npm install routopia2. Define Routes
import { routes, empty, type } from 'routopia';
export const myRoutes = routes({
"/users": {
get: empty,
post: empty,
},
"/path/[id]": {
get: {
params: {
id: type as number,
},
queries: {
q: type as string | undefined,
},
},
},
});3. Use Routes
import { myRoutes } from './path/to/myRoutes';
myRoutes["/users"].get();
myRoutes["/users"].post();
// => "/users"
myRoutes["/path/[id]"].get({ params: { id: 123 } });
// => "/path/123"
myRoutes["/path/[id]"].get({ params: { id: 123 }, queries: { q: "query" } });
// => "/path/123?q=query"[!TIP]
The return value is inferred in detail by template literal types.
For example, ifconst path = myRoutes["/users"].get(), the type of path will be"/users".
If you want to receive it as a string type, please add a type annotation:const path: string = myRoutes["/users"].get()
📘 Why routopia?
Provides type-safe route definitions including path parameters and query parameters, powered by strong type inference and IDE autocompletion features.
The main differences from other libraries are as follows:
- Autocomplete works during definition.
- Autocomplete guides you to the correct path during usage.
- Detailed inference is obtained through template literal types.
routopia focuses on declaratively and simply getting type-safe URLs.
If you need more advanced features like automatic generation or regular expressions, other libraries might be better.
Conversely, routopia might be a good match for the following cases:
- When you need to handle links to external sites or third-party API endpoints without an available SDK
- Managing URLs with custom schemas (e.g.,
myapp://path/to/resource) - When you're not using ecosystems like OpenAPI generators for some reason
- When you want to simply use features like Next.js Route Handlers
- When managing simple internal links or using typedRoutes in Next.js
- When you need a simple URL builder
📖 API Reference
No Parameters
- Specify
emptyif no parameters are needed.
import { routes, empty } from 'routopia';
const myRoutes = routes({
"/path": {
get: empty,
post: empty,
put: empty,
delete: empty,
},
});
myRoutes["/path"].get();
myRoutes["/path"].post();
myRoutes["/path"].put();
myRoutes["/path"].delete();
// => All resolve to "/path"Path Parameters
- Enclose with
[]like[param]. - Multiple path parameters can also be specified.
- Path parameters are defined within the
paramsobject. - Specify the
typeof path parameters using type assertion (type as {Type}or<{Type}>type) with the dedicated type object. - The type of path parameters can be specified satisfying
string | number.
import { routes, type } from 'routopia';
const myRoutes = routes({
"/path/[id]": {
get: {
params: {
id: type as number,
// <number>type is also OK
},
},
},
"/path/[param1]/[param2]": {
get: {
params: {
param1: type as string,
param2: type as string | number,
},
},
},
});
myRoutes["/path/[id]"].get({ params: { id: 123 } });
// => "/path/123"
myRoutes["/path/[id]"].get({ params: { id: "abc" } });
// ^^^^^
// Error: Type 'string' is not assignable to type 'number'
myRoutes["/path/[param1]/[param2]"].get({
params: { param1: "abc", param2: 123 },
});
// => "/path/abc/123"
myRoutes["/path/[id]"].get();
// ^^^^
// Error: Path parameters cannot be omitted when called.Catch-all Parameters
- Define catch-all parameters like
[...param]. - The type of catch-all parameters can be specified satisfying
(string | number)[]. - Using double brackets like
[[...param]]allowsundefinedin addition to the above. - This feature is equivalent to Next.js's Catch-all Segments.
import { routes, type } from 'routopia';
const myRoutes = routes({
"/path/[...slug]": {
get: {
params: {
slug: type as string[],
},
},
},
"/path/[[...slug]]": {
get: {
params: {
slug: type as number[],
},
},
},
});
myRoutes["/path/[...slug]"].get({
params: { slug: ["abc", "def"] },
});
// => "/path/abc/def"
myRoutes["/path/[...slug]"].get();
// ^^^^
// Error: Catch-all parameters cannot be omitted when called.
myRoutes["/path/[[...slug]]"].get({
params: { slug: [123, 456] },
});
// => "/path/123/456"
myRoutes["/path/[[...slug]]"].get();
// => "/path"
// Optional Catch-all parameters can be omitted when called.Query Parameters
- Query parameters are defined within the
queriesobject. - Types other than
objectcan be specified for query parameters. - Including
undefinedmakes them optional (omittable).
import { routes, type } from 'routopia';
const myRoutes = routes({
"/required": {
get: {
queries: {
str: type as string,
num: type as number,
bool: type as boolean,
arr: type as string[],
opt: type as string | undefined,
},
},
},
"/optional": {
get: {
queries: {
str: type as string | undefined,
num: type as number | undefined,
bool: type as boolean | undefined,
arr: type as string[] | undefined,
},
},
},
});
myRoutes["/required"].get({
queries: {
str: "abc",
num: 123,
bool: true,
arr: ["a", "b", "c"]
},
});
// => "/required?arr=a&arr=b&arr=c&bool=true&num=123&str=abc"
myRoutes["/required"].get();
// ^^^
// Error: Cannot be omitted if there are required query parameters.
myRoutes["/optional"].get();
// => "/optional"[!TIP] The order of query parameters is sorted, making it compatible with caching mechanisms like SWR that use URLs as keys.
Hash
- Define the
hashkey if needed. - The
hashkey can be specified satisfying thestringtype. - Using a Union type is safer for actual use, but specifying
stringto accept anything is also possible. - The
hashkey is always omittable, even without includingundefined.
import { routes, type } from 'routopia';
const myRoutes = routes({
"/path": {
get: {
hash: type as "anchor1" | "anchor2",
},
},
"/any": {
get: {
hash: type as string,
},
},
});
myRoutes["/path"].get({ hash: "anchor1" });
// => "/path#anchor1"
myRoutes["/path"].get({ hash: "unknown" });
// ^^^^^
// Error: Type '"unknown"' is not assignable to type '"anchor1" | "anchor2"'.
myRoutes["/any"].get({ hash: "unknown" });
// => "/any#unknown"
myRoutes["/path"].get();
// => "/path"Base URL
- The routes function can accept a string as the first argument to specify a Base URL.
- In that case, specify the schema definition as the second argument.
- Note that the Base URL is simply prefixed.
import { routes, empty } from 'routopia';
const myUsersRoutes = routes("/users", {
"/path": {
get: empty,
},
});
myUsersRoutes["/path"].get();
// => "/users/path"
const myApiRoutes = routes("https://api.example.com", {
"/path": {
get: empty,
},
});
myApiRoutes["/path"].get();
// => "https://api.example.com/path"Shorthand
- You can directly define parameters by omitting the HTTP method definition.
- In this case, it is equivalent to defining the GET method.
import { routes, empty, type } from 'routopia';
const myRoutes = routes({
"/short": empty,
// = "/short": { get: empty }
"/short/[param]": {
params: {
param: type as string,
},
queries: {
q: type as string | undefined,
},
hash: type as string,
},
// = "/short/[param]": { get: { params: {...}, queries: {...}, hash: ... } }
});
// Use it by calling the `get` method
myRoutes["/short"].get();
// => "/short"
myRoutes["/short/[param]"].get({
params: { param: "abc" },
queries: { q: "query" },
hash: "anchor",
});
// => "/short/abc?q=query#anchor"[!WARNING] When using Shorthand, you cannot combine it with other HTTP method definitions within the same endpoint.
While it's possible to use them together across different endpoints, you should split definition files to prevent notation inconsistencies within the same domain.import { routes, empty, type } from 'routopia'; // ❌: Cannot combine Shorthand notation with other method definitions within the same endpoint const error = routes({ "/short/mixed": { queries: { q: type as string }, post: empty, // ^^^^^^^^^^^ }, }); // ⚠️: Can be used together across different endpoints, but causes notation inconsistencies const notGood = routes({ "/hoge": empty, // : "/foo": { get: empty }, }); // ✅: Unify notation between endpoints within the same file const good = routes({ "/hoge": { get: empty }, // : "/foo": { get: empty }, }); // ✅: Or split definition files and unify notation by domain // hoge.ts const hogeRoutes = routes({ "/hoge": empty, }); // foo.ts const fooRoutes = routes({ "/foo": { get: empty }, });
Best Practices
- Create an abstraction layer by wrapping
routopia. - It's also possible to specify the Base URL collectively.
- Use
ExpectedSchemawith generics for the argument type when wrapping. - Split definition files by domain as needed to prevent them from becoming too large
// createMyApiRoutes.ts
import { routes, empty, type, ExpectedSchema } from 'routopia';
const API_BASE_URL = "https://api.example.com";
export function createMyApiRoutes<T extends ExpectedSchema<T>>(schema: T) {
return routes(API_BASE_URL, schema);
}
export const schema = { empty, type };// userRoutes.ts
import { createMyApiRoutes, schema } from './path/to/createMyApiRoutes';
export const userRoutes = createMyApiRoutes({
"/users": schema.empty,
"/users/[id]": {
params: {
id: schema.type as number,
},
},
});// postRoutes.ts
import { createMyApiRoutes, schema } from './path/to/createMyApiRoutes';
export const postRoutes = createMyApiRoutes({
"/posts": {
get: { queries: { q: schema.type as string | undefined } },
},
"/posts/[id]": {
get: { params: { id: schema.type as number } },
post: { params: { id: schema.type as number } },
put: { params: { id: schema.type as number } },
delete: { params: { id: schema.type as number } },
},
});// Example Usage
import { userRoutes } from './path/to/userRoutes';
import { postRoutes } from './path/to/postRoutes';
userRoutes["/users"].get();
// => "https://api.example.com/users"
postRoutes["/posts/[id]"].get({ params: { id: 123 } });
// => "https://api.example.com/posts/123"[!WARNING] For convenience, it's possible to combine multiple route definitions as shown below, but be careful as TreeShaking will not work effectively
// index.ts import { userRoutes } from './path/to/userRoutes'; import { postRoutes } from './path/to/postRoutes'; export const apiRoutes = { ...userRoutes, ...postRoutes, }; // Example Usage import { apiRoutes } from './path/to/index'; apiRoutes["/users"].get(); apiRoutes["/posts/[id]"].get({ params: { id: 123 } }); // You can search all routes uniformly, but the bundle size will increase
