@typed/route
Type-safe, bi-directional routing with Effect's Schema.

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'))
])