@typed/route
Type-safe, bi-directional routing with Effect's Schema.
The key benefit of @typed/route is type-safety through type-level parsing of strings utilizing TypeScript's string literal types.
When constructing Routes, it knows exactly what path syntax is being constructed and the types of the parsed parameters.
When interpolating Routes, it knows exactly what path will be constructed.

Features
- ๐ฏ Type-safe routing - Catch routing errors at compile time
- ๐ Bi-directional routing - Parse URLs to typed parameters and generate URLs from parameters
- ๐ Schema-based - Leverage Effect's
Schema for robust parameter validation
- ๐ณ Path composition - Build complex routes by combining path segments
- ๐ Pattern matching - Match URLs against route patterns with type inference
- ๐ Query parameters - Support for optional and required query parameters
- ๐ญ Route transformation - Transform route parameters between different shapes
Installation
npm install @typed/route
pnpm add @typed/route
yarn add @typed/route
Usage Guide
Basic Route Creation
import { Route } from '@typed/route'
import { Option } from 'effect'
const articles = Route.literal('articles')
const article = Route.literal('articles').concat(Route.param('slug'))
const userProfile = Route.literal('user')
.concat(Route.integer('userId'))
Route Composition
Routes can be composed together to create more complex paths:
const articleComments = article.concat(Route.literal('comments'))
const specificComment = articleComments.concat(Route.param('commentId'))
const fooPrefixed = Route.literal('foo')
.concat(Route.integer('fooId').prefix('-'))
const fooSeparated = Route.literal('foo')
.concat(Route.separator, Route.integer('fooId').prefix('foo-'))
Parameter Types
The library supports various parameter types:
const basic = Route.param('test')
const optional = Route.param('test').optional()
const zeroOrMore = Route.param('test').zeroOrMore()
const oneOrMore = Route.param('test').oneOrMore()
Query Parameters
Support for URL query parameters:
const searchRoute = Route.home.concat(
Route.queryParams({
tag: Route.param('tag').optional(),
limit: Route.param('limit').optional(),
offset: Route.param('offset').optional()
})
)
Route Matching and Interpolation
const result = articleRoute.match('/articles/123')
const url = articleRoute.interpolate({ slug: '123' })
Route Transformation
Transform route parameters between different shapes:
import { Schema } from 'effect'
const transformedRoute = route.pipe(
Route.transform(
Schema.Struct({
foo: Schema.Int,
bar: Schema.Int
}),
({ paramId }) => ({ foo: paramId, bar: paramId + 1 }),
({ foo, bar }) => ({ paramId: foo })
)
)
Decoding and Encoding
The library provides utilities for type-safe decoding and encoding:
import { Effect } from 'effect'
const params = await Effect.runPromise(
Route.decode(articleRoute, '/articles/123')
)
const url = await Effect.runPromise(
Route.encode(articleRoute, { slug: '123' })
)
Separate Path and Query Schemas
You can work with path and query parameters separately:
const route = Route.literal('/foo')
.concat(
Route.integer('fooId'),
Route.queryParams({ bar: Route.integer('bar') })
)
const { pathSchema, querySchema } = route
Route Prefixing
Add prefixes to parameter values:
const prefixedRoute = Route.integer('id').prefix('user-')
Route Parsing and Utilities
const route = Route.parse('/articles/:slug')
const path = Route.getPath(route)
const isRoute = Route.isRoute(value)
For more examples and advanced usage, check out the test file in the repository.
Route Constructors
parse
Convert strings into a Route
import { Route } from '@typed/route'
const userRoute = Route.parse('/users/:id')
const searchRoute = Route.parse('/search?q=:query')
const articleRoute = Route.parse('/blog/:year/:month/:slug')
literal
Create string literal portions of the route path
import { Route } from '@typed/route'
const home = Route.literal('home')
const userProfile = Route.literal('users').concat(Route.param('userId'))
separator
Create a path separator (/)
import { Route } from '@typed/route'
const homeRoute = Route.separator
const homeWithSearch = Route.home.concat(
Route.queryParams({
q: Route.param('query').optional()
})
)
home
Create a route for the root path, expects to be the ENTIRE path.
import { Route } from '@typed/route'
const homeRoute = Route.home
const homeWithSearch = Route.home.concat(
Route.queryParams({
q: Route.param('query').optional()
})
)
param
Create a route parameter with string type
import { Route } from '@typed/route'
const userRoute = Route.literal('users').concat(Route.param('userId'))
const articleRoute = Route.literal('blog')
.concat(Route.param('category'))
.concat(Route.param('slug'))
const searchRoute = Route.param('query').optional()
paramWithSchema
Create a route parameter with a custom schema
import { Route } from '@typed/route'
import { Schema } from 'effect'
const userRoute = Route.literal('users').concat(
Route.paramWithSchema('userId', Schema.NumberFromString)
)
const dateRoute = Route.paramWithSchema('date', Schema.Date)
number
Create a route parameter that parses to a number
import { Route } from '@typed/route'
const userRoute = Route.literal('users').concat(Route.number('userId'))
const pageRoute = Route.literal('posts').concat(
Route.queryParams({
page: Route.number('page'),
limit: Route.number('limit')
})
)
integer
Create a route parameter that parses to an integer
import { Route } from '@typed/route'
const productRoute = Route.literal('products').concat(Route.integer('productId'))
const paginatedRoute = Route.literal('articles').concat(
Route.queryParams({
page: Route.integer('page')
})
)
BigInt
Create a route parameter that parses to a BigInt
import { Route } from '@typed/route'
const largeIdRoute = Route.literal('records').concat(Route.BigInt('recordId'))
const timeRoute = Route.literal('events').concat(Route.BigInt('timestamp'))
bigDecimal
Create a route parameter that parses to a BigDecimal
import { Route } from '@typed/route'
const priceRoute = Route.literal('products').concat(Route.bigDecimal('price'))
const locationRoute = Route.literal('map').concat(
Route.bigDecimal('latitude')
).concat(Route.bigDecimal('longitude'))
base64Url
Create a route parameter that parses base64url-encoded data
import { Route } from '@typed/route'
const tokenRoute = Route.literal('verify').concat(Route.base64Url('token'))
const dataRoute = Route.literal('data').concat(Route.base64Url('payload'))
boolean
Create a route parameter that parses to a boolean
import { Route } from '@typed/route'
const featureRoute = Route.literal('features').concat(
Route.queryParams({
enabled: Route.boolean('enabled')
})
)
const settingRoute = Route.literal('settings').concat(Route.boolean('active'))
ulid
Create a route parameter that validates ULIDs
import { Route } from '@typed/route'
const documentRoute = Route.literal('documents').concat(Route.ulid('documentId'))
const lookupRoute = Route.queryParams({
id: Route.ulid('recordId')
})
uuid
Create a route parameter that validates UUIDs
import { Route } from '@typed/route'
const userRoute = Route.literal('users').concat(Route.uuid('userId'))
const batchRoute = Route.literal('batch').concat(
Route.queryParams({
ids: Route.uuid('id').oneOrMore()
})
)
date
Create a route parameter that parses to a Date
import { Route } from '@typed/route'
const eventRoute = Route.literal('events').concat(Route.date('eventDate'))
const rangeRoute = Route.literal('reports').concat(
Route.queryParams({
start: Route.date('startDate'),
end: Route.date('endDate')
})
)
unnamed
Create an unnamed route parameter
import { Route } from '@typed/route'
const catchAllRoute = Route.literal('files').concat(Route.unnamed)
const deepRoute = Route.literal('docs').concat(Route.unnamed.zeroOrMore())
zeroOrMore
Match zero or more occurrences of a route
import { Route } from '@typed/route'
const filesRoute = Route.literal('files').concat(Route.param('path').zeroOrMore())
const tagsRoute = Route.literal('posts').concat(
Route.queryParams({
tags: Route.param('tag').zeroOrMore()
})
)
oneOrMore
Match one or more occurrences of a route
import { Route } from '@typed/route'
const pathRoute = Route.literal('path').concat(Route.param('segment').oneOrMore())
const multiRoute = Route.literal('items').concat(
Route.queryParams({
id: Route.number('id').oneOrMore()
})
)
optional
Make a route parameter optional
import { Route } from '@typed/route'
const userRoute = Route.literal('users').concat(Route.param('userId').optional())
const searchRoute = Route.literal('search').concat(
Route.queryParams({
q: Route.param('query').optional(),
page: Route.number('page').optional()
})
)
prefix
Add a prefix to route parameters
import { Route } from '@typed/route'
const userRoute = Route.number('userId').prefix('user-')
const tagRoute = Route.param('tag').prefix('tag/')
concat
Combine multiple routes together
import { Route } from '@typed/route'
const userPostRoute = Route.literal('users')
.concat(Route.param('userId'))
.concat(Route.literal('posts'))
.concat(Route.param('postId'))
const searchRoute = Route.literal('search')
.concat(Route.queryParams({
q: Route.param('query'),
page: Route.number('page').optional()
}))
queryParams
Add query parameters to a route
import { Route } from '@typed/route'
const searchRoute = Route.literal('search').concat(
Route.queryParams({
q: Route.param('query'),
page: Route.number('page').optional(),
limit: Route.number('limit').optional()
})
)
const filterRoute = Route.literal('products').concat(
Route.queryParams({
category: Route.param('category').optional(),
minPrice: Route.number('minPrice').optional(),
maxPrice: Route.number('maxPrice').optional(),
tags: Route.param('tag').zeroOrMore()
})
)
Schemas
withSchema
Add a custom schema to a route
import { Route } from '@typed/route'
import { Schema } from 'effect'
const userRoute = Route.literal('users')
.concat(Route.param('userId'))
.pipe(Route.withSchema(Schema.Struct({
userId: Schema.NumberFromString
})))
const dateRoute = Route.literal('events')
.concat(Route.param('date'))
.pipe(Route.withSchema(Schema.transform(
Schema.DateFromSelf,
Schema.String,
(date) => date.toISOString(),
(str) => new Date(str)
)))
decode
Decode a URL path to typed parameters
import { Route } from '@typed/route'
import { Effect } from 'effect'
const userRoute = Route.literal('users').concat(Route.number('userId'))
const result = await Effect.runPromise(
Route.decode(userRoute, '/users/123')
)
const program = Route.decode(userRoute, '/users/invalid')
.pipe(
Effect.catchTags({
RouteNotMatched: () => ...,
RouteDecodeError: ({ route, issue }) => ...
})
)
encode
Encode parameters to a URL path
import { Route } from '@typed/route'
import { Effect } from 'effect'
const userRoute = Route.literal('users').concat(Route.number('userId'))
const path = await Effect.runPromise(
Route.encode(userRoute, { userId: 123 })
)
const program = Effect.tryPromise(() =>
Route.encode(userRoute, { userId: 'invalid' })
.pipe(
Effect.catchTags({
RouteEncodeError: ({ route, issue }) => ...
})
)
)
updateSchema
Update a route's schema
import { Route } from '@typed/route'
import { Schema } from 'effect'
const userRoute = Route.literal('users')
.concat(Route.param('userId'))
.pipe(Route.updateSchema((schema) =>
Schema.compose(
schema,
Schema.Struct({
userId: Schema.String.pipe(Schema.minLength(5))
})
)
))
transform
Transform route parameters between different shapes
import { Route } from '@typed/route'
import { Schema } from 'effect'
const userRoute = Route.literal('users')
.concat(Route.param('userId'))
.pipe(Route.transform(
Schema.Struct({ id: Schema.Number }),
({ userId }) => ({ id: Number(userId) }),
({ id }) => ({ userId: String(id) })
))
const dateRoute = Route.literal('events')
.concat(Route.param('date'))
.pipe(Route.transform(
Schema.Date,
({ date }) => new Date(date),
(date) => ({ date: data.toISOString() })
))
transformOrFail
Transform route parameters with possible failure using an Effect
import { Route } from '@typed/route'
import { Schema, Effect } from 'effect'
const userRoute = Route.literal('users')
.concat(Route.param('userId'))
.pipe(Route.transformOrFail(
Schema.Struct({ id: Schema.Number }),
({ userId }) => Effect.succeed({ id: Number(userId) }),
({ id }) => Effect.succeed({ userId: String(id) })
))
attachPropertySignature
Add a property to route parameters
import { Route } from '@typed/route'
const apiRoute = Route.literal('api')
.concat(Route.param('endpoint'))
.pipe(Route.attachPropertySignature('version', 'v1'))
const userRoute = Route.literal('users')
.concat(Route.param('userId'))
.pipe(
Route.attachPropertySignature('type', 'user'),
Route.attachPropertySignature('source', 'database')
)
addTag
Add a discriminant tag to route parameters
import { Route } from '@typed/route'
const userRoute = Route.literal('users')
.concat(Route.param('userId'))
.pipe(Route.addTag('user'))
const postRoute = Route.literal('posts')
.concat(Route.integer('postId'))
.pipe(Route.addTag('post'))
Utilities
sortRoutes
Sort routes by specificity for proper matching
import { Route } from '@typed/route'
const routes = Route.sortRoutes([
Route.literal('users').concat(Route.param('userId')),
Route.literal('users'),
Route.literal('users').concat(Route.literal('settings')),
Route.literal('users').concat(Route.param('userId'), Route.literal('posts'))
])