ruroute
v0.6.0
Published
Type-safe route definitions and URL builder
Maintainers
Readme
ruroute
A TypeScript library that builds type-safe URLs from route definitions
✨ Features
- 🔒 Type-safe route definitions, down to path params, query and hash
- 🎯 The returned URL is inferred as a template literal type
- 🪶 No dependencies. Unused
type.*markers are removed by tree shaking - ⚡ Low runtime cost (benchmark)
- 📝 JSDoc written in the definitions shows up on call arguments
- 🌐 Supports a base URL, absolute URLs and custom schemes (
myapp://…)
🚀 Getting Started
1. Install
pnpm add ruroute2. Define Routes
import { defineRoutes, type } from 'ruroute'
export const routes = defineRoutes({
routes: {
'/users': type.route({
get: { query: { page: type.optional(type.number) } },
post: type.empty,
}),
'/users/[id]': type.route({
get: { params: { id: type.number } },
}),
},
})3. Use Routes
import { routes } from './routes'
routes['/users']()
routes['/users'].get()
routes['/users'].post()
// => '/users'
routes['/users']({ query: { page: 2 } })
// => '/users?page=2'
routes['/users/[id]']({ params: { id: 123 } })
// => '/users/123'[!TIP] The return value is inferred as a template literal type.
For example, the type ofroutes['/users/[id]']({ params: { id: 123 } })is`/users/${number}`.
To receive it asstring, add a type annotation:const url: string = routes['/users/[id]']({ params: { id: 123 } })
📘 Why ruroute?
A library focused on declaring routes in one place and getting typed URL builders from them. Its main characteristics:
- Schemas are written as values such as
type.number, so no type assertions are needed - Calling a route directly returns the GET URL, and argument keys can be shortened to
p/q/h - JSDoc written in the definitions carries over to call arguments
- Runtime work is kept light, making URL building and route definition fast
It fits cases like:
- Managing endpoints of third-party APIs without an SDK, or links to external sites
- Managing custom scheme URLs (e.g.
myapp://items/1) - Managing API URLs in projects that don't use code generation such as OpenAPI Generator
- Making internal links type-safe in a simple way
📖 API Reference
- No Parameters
- Path Parameters
- Query Parameters
- Hash
- Schema Types
- Base URL
- Shorthand
- JSDoc
- Type Utilities
- URL Encoding
No Parameters
- Use
type.emptyfor methods that take no parameters - Methods are
get/post/put/delete. Any other key is a type error - Only the defined methods are available as
.get/.post/.put/.delete '/path': type.emptymakes a GET-only route (Shorthand)
import { defineRoutes, type } from 'ruroute'
const routes = defineRoutes({
routes: {
'/items': type.route({
get: type.empty,
post: type.empty,
put: type.empty,
delete: type.empty,
}),
},
})
routes['/items'].get()
routes['/items'].post()
routes['/items'].put()
routes['/items'].delete()
// => all '/items'Path Parameters
- Wrap a segment in
[], like[name] - Define the values in
params. Any type that satisfiesstring/numbercan be used (Schema Types) [name]in the path and theparamskeys are checked against each other at definition time. Missing or extra keys are type errors- Define the path params in every method of the route
- Values are encoded with
encodeURIComponent
import { defineRoutes, type } from 'ruroute'
const routes = defineRoutes({
routes: {
'/users/[id]': type.route({
get: { params: { id: type.number } },
delete: { params: { id: type.number } },
}),
'/orgs/[org]/repos/[repo]': type.route({
get: { params: { org: type.string, repo: type.string } },
}),
},
})
routes['/users/[id]']({ params: { id: 123 } })
// => '/users/123'
routes['/users/[id]']({ params: { id: 'abc' } })
// ^^
// Error: string is not assignable to number
routes['/orgs/[org]/repos/[repo]']({ params: { org: 'mokume3', repo: 'my repo' } })
// => '/orgs/mokume3/repos/my%20repo'
routes['/users/[id]']()
// ^^
// Error: path params cannot be omitteddefineRoutes({
routes: {
// Error: no params for [id] in the path
'/users/[id]': type.route({
get: { params: { userId: type.number } },
}),
},
})Query Parameters
- Define the values in
query.string/number/booleanand arrays of them can be used - Keys wrapped in
type.optional(...)can be omitted when calling - If there are no required keys, the argument itself can be omitted
- Passing a key that isn't defined is a type error
- Arrays repeat the key (
tags=a&tags=b).undefinedkeys and empty arrays are not output
import { defineRoutes, type } from 'ruroute'
const routes = defineRoutes({
routes: {
'/search': type.route({
get: {
query: {
q: type.string,
page: type.optional(type.number),
exact: type.optional(type.boolean),
tags: type.optional(type.array(type.string)),
},
},
}),
'/posts': type.route({
get: { query: { page: type.optional(type.number) } },
}),
},
})
routes['/search']({ query: { q: 'type safe', page: 2, tags: ['ts', 'url'] } })
// => '/search?page=2&q=type%20safe&tags=ts&tags=url'
routes['/search']()
// ^^
// Error: cannot be omitted because the query q is required
routes['/search']({ query: { q: 'url', sort: 'new' } })
// ^^^^
// Error: sort is not defined
routes['/posts']()
// => '/posts'[!TIP] Query keys are sorted in the output, so the same values always produce the same URL. This works as-is with SWR or TanStack Query, which use URLs as cache keys. The return type follows the sorted order too.
Hash
- Define the value in
hash. Any type that satisfiesstring/numbercan be used - Narrowing the candidates with a union of literal types is safer, but
type.stringaccepts anything - Unless wrapped in
type.optional(...), it cannot be omitted when calling - No
#is added forundefinedor an empty string
import { defineRoutes, type } from 'ruroute'
const routes = defineRoutes({
routes: {
'/docs': type.route({
get: { hash: type.optional(type.literal(['install', 'usage'])) },
}),
'/faq': type.route({
get: { hash: type.string },
}),
},
})
routes['/docs']({ hash: 'usage' })
// => '/docs#usage'
routes['/docs']({ hash: 'unknown' })
// ^^^^
// Error: 'unknown' is not assignable to 'install' | 'usage'
routes['/docs']()
// => '/docs'
routes['/faq']()
// ^^
// Error: hash cannot be omittedSchema Types
| Type | TS type | params | query | hash |
|---|---|:---:|:---:|:---:|
| type.string | string | ✅ | ✅ | ✅ |
| type.number | number | ✅ | ✅ | ✅ |
| type.boolean | boolean | – | ✅ | – |
| type.literal([...]) | union of the values | ✅ | ✅ | ✅ |
| type.union([...]) | union of the element types | ✅ | ✅ | ✅ |
| type.custom<T>() | T | ✅ | ✅ | ✅ |
| type.array(T) | readonly T[] | – | ✅ | – |
| type.optional(T) | T \| undefined (the key can be omitted) | – | ✅ | ✅ |
type.custom<T>()lets you use branded types or existing union types as they are.Tmust be a subtype ofstring | numberfor params / hash, and ofstring | number | booleanfor query- Values are not validated at runtime.
type.*are markers for types only, and at runtime they are all the same empty object
import { defineRoutes, type } from 'ruroute'
type UserId = string & { readonly __brand: 'UserId' }
const routes = defineRoutes({
routes: {
'/users/[id]/posts': type.route({
get: {
params: { id: type.custom<UserId>() },
query: {
sort: type.optional(type.literal(['new', 'popular'])),
limit: type.optional(type.union([type.number, type.literal(['all'])])),
},
},
}),
},
})
declare const userId: UserId
routes['/users/[id]/posts']({ params: { id: userId }, query: { sort: 'new', limit: 'all' } })
// => '/users/<userId>/posts?limit=all&sort=new'Base URL
- When
baseUrlis set, it is prepended to each route key - When
baseUrlends with/and the key starts with/, one trailing/is removed frombaseUrl - When
baseUrlis set, keys must be paths starting with/(but not//). Otherwise it is a type error - Without
baseUrl, keys can be absolute URLs or custom scheme URLs as they are
import { defineRoutes, type } from 'ruroute'
const api = defineRoutes({
baseUrl: 'https://api.example.com',
routes: {
'/users': type.empty,
},
})
api['/users']()
// => 'https://api.example.com/users'
const app = defineRoutes({
baseUrl: 'myapp://',
routes: {
'/items/[id]': type.route({ get: { params: { id: type.number } } }),
},
})
app['/items/[id]']({ params: { id: 1 } })
// => 'myapp://items/1'
const links = defineRoutes({
routes: {
'https://github.com/[owner]/[repo]': type.route({
get: { params: { owner: type.string, repo: type.string } },
}),
'myapp://settings': type.empty,
},
})
links['https://github.com/[owner]/[repo]']({ params: { owner: 'mokume3', repo: 'ruroute' } })
// => 'https://github.com/mokume3/ruroute'defineRoutes({
baseUrl: 'https://api.example.com',
routes: {
// Error: 'baseUrl を指定したときはキーを / で始まるパスにしてください'
// (when baseUrl is set, keys must be paths starting with /)
users: type.empty,
},
})Shorthand
'/path': type.emptymakes a GET-only route. Callingtype.route()with no arguments is the same- Calling a route directly returns the same URL as
.get. Routes without GET cannot be called directly - Argument keys can be written as
p/q/hinstead ofparams/query/hash. Passing the same item both ways is a type error
import { defineRoutes, type } from 'ruroute'
const routes = defineRoutes({
routes: {
'/health': type.empty,
// = '/health': type.route({ get: type.empty })
'/users/[id]': type.route({
get: {
params: { id: type.number },
query: { tab: type.optional(type.literal(['posts', 'likes'])) },
hash: type.optional(type.string),
},
}),
},
})
routes['/health']()
routes['/health'].get()
// => '/health'
routes['/users/[id]']({ p: { id: 1 }, q: { tab: 'posts' }, h: 'top' })
routes['/users/[id]'].get({ params: { id: 1 }, query: { tab: 'posts' }, hash: 'top' })
// => '/users/1?tab=posts#top'
routes['/users/[id]']({ params: { id: 1 }, p: { id: 2 } })
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Error: params and p cannot be passed togetherJSDoc
- JSDoc written on
params/querykeys and onhashalso shows up on call arguments - It shows up with the shorthand
p/qtoo. It shows up onhonly in TypeScript 7.1 and later
import { defineRoutes, type } from 'ruroute'
const routes = defineRoutes({
routes: {
'/users': type.route({
get: {
query: {
/** 1-based page number */
page: type.optional(type.number),
},
},
}),
},
})
routes['/users']({ query: { page: 2 } })
// ^^^^
// Hovering shows "1-based page number"Type Utilities
RoutePath<T>: a union of the route pathsRouteArgs<T, P, M>: the call argument type for pathPand methodM.neverfor methods with no argumentsValidRoutes<R, B>: validates route definitions. Use it for the argument of a function that wrapsdefineRoutesSchema/Infer/Empty/Route/RouteDef/Routes/RoutesConfigare also exported
import type { RouteArgs, RoutePath } from 'ruroute'
type Path = RoutePath<typeof routes>
// => '/users' | '/users/[id]'
type Args = RouteArgs<typeof routes, '/users/[id]', 'get'>
// => the call argument type, such as { params: { id: number } } | { p: { id: number } }URL Encoding
| Part | Rule |
|---|---|
| baseUrl | Joined with the key. When baseUrl ends with / and the key starts with /, one trailing / is removed from baseUrl (myapp:// + /items → myapp://items) |
| params | [name] is replaced with encodeURIComponent(String(value)) |
| query | Built with URLSearchParams, with keys sorted and spaces as %20. undefined keys and empty arrays are not output. Arrays repeat the key. No ? when empty |
| hash | # + encodeURIComponent(String(value)). Not added for undefined or an empty string |
🚫 Unsupported
- Catch-all path params (
[...slug]/[[...slug]]) and optional path params.[...slug]gets no special treatment and is handled as a regular path param named...slug - Methods other than
get/post/put/delete(such aspatch) - A
mockmethod for tests - URL parsing and runtime value validation
- IPv6 literal hosts in keys (
http://[::1]/…). They work in baseUrl
If you need catch-all path params or a mock method for tests, routopia is a better fit.
📊 Benchmark
Compared with routopia 1.2.3 (measured on 2026-11-06). See docs/benchmark.md (in Japanese) for the method and details.
| Case | ruroute / routopia | |---|---:| | No parameters | 5.27x | | Path params + hash | 5.00x | | Query (with arrays) | 2.38x | | params + query + hash | 2.49x | | Absolute URL keys | 5.76x | | Route definition (100 routes) | 4.09x |
| Bundle (define 3 routes and call them 4 times) | minified | gzip | brotli | |---|---:|---:|---:| | ruroute | 1,208 B | 713 B | 636 B | | routopia | 1,975 B | 821 B | 725 B |
