Skip to content

TylorS/typed-route

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@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.

npm version

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
# or
pnpm add @typed/route
# or
yarn add @typed/route

See it in Action

Here's some examples you can try out in your browser:

Be sure to hover over the routes to see the inferred types, and open your console to see the printed outputs.

Usage Guide

Basic Route Creation

import { Route } from '@typed/route'
import { Option } from 'effect'

// Create routes using literals
const articles = Route.literal('articles')

// Create routes with parameters
const article = Route.literal('articles').concat(Route.param('slug'))

// Create routes with integers
const userProfile = Route.literal('user')
  .concat(Route.integer('userId'))

Route Composition

Routes can be composed together to create more complex paths:

// Combine multiple route segments
const articleComments = article.concat(Route.literal('comments'))
const specificComment = articleComments.concat(Route.param('commentId'))

// Use separators for clean paths

// Matches and generates paths like '/foo-123'
const fooPrefixed = Route.literal('foo')
  .concat(Route.integer('fooId').prefix('-'))

// Matches paths like '/foo/foo-123
const fooSeparated = Route.literal('foo')
  .concat(Route.separator, Route.integer('fooId').prefix('foo-'))

Parameter Types

The library supports various parameter types:

// Basic parameters
const basic = Route.param('test')

// Optional parameters
const optional = Route.param('test').optional()

// Zero or more parameters
const zeroOrMore = Route.param('test').zeroOrMore()

// One or more parameters
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()
  })
)

// Matches URLs like: /?tag=javascript&limit=10&offset=20

Route Matching and Interpolation

// Match a URL against a route
const result = articleRoute.match('/articles/123')
// Returns Option.some({ slug: '123' }) if matched
// Returns Option.none() if not matched

// Generate a URL from parameters
const url = articleRoute.interpolate({ slug: '123' })
// Returns '/articles/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
    }),
    // Transform from route params to your shape
    ({ paramId }) => ({ foo: paramId, bar: paramId + 1 }),
    // Transform back to route params
    ({ foo, bar }) => ({ paramId: foo })
  )
)

Decoding and Encoding

The library provides utilities for type-safe decoding and encoding:

import { Effect } from 'effect'

// Decode a URL path to typed parameters
const params = await Effect.runPromise(
  Route.decode(articleRoute, '/articles/123')
)

// Encode parameters to a URL
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-')
// Will match and generate URLs like: /user-123

Route Parsing and Utilities

// Parse a string path into a Route
const route = Route.parse('/articles/:slug')

// Get the path string from a Route
const path = Route.getPath(route)

// Check if a value is a 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'

// Parse a simple path
const userRoute = Route.parse('/users/:id')

// Parse a path with query parameters
const searchRoute = Route.parse('/search?q=:query')

// Parse a path with multiple parameters
const articleRoute = Route.parse('/blog/:year/:month/:slug')

literal

Create string literal portions of the route path

import { Route } from '@typed/route'

// Create a simple literal route
const home = Route.literal('home')

// Combine literals with other route types
const userProfile = Route.literal('users').concat(Route.param('userId'))

separator

Create a path separator (/)

import { Route } from '@typed/route'

// Match the root path
const homeRoute = Route.separator // matches "/*"

// Add query parameters to home route
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'

// Match the root path
const homeRoute = Route.home // matches "/"

// Add query parameters to home route
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'

// Simple parameter
const userRoute = Route.literal('users').concat(Route.param('userId'))

// Multiple parameters
const articleRoute = Route.literal('blog')
  .concat(Route.param('category'))
  .concat(Route.param('slug'))

// Optional parameter
const searchRoute = Route.param('query').optional()

paramWithSchema

Create a route parameter with a custom schema

import { Route } from '@typed/route'
import { Schema } from 'effect'

// Create a parameter with a custom schema, must start as a String
const userRoute = Route.literal('users').concat(
  Route.paramWithSchema('userId', Schema.NumberFromString)
)

// Parameter with complex schema
const dateRoute = Route.paramWithSchema('date', Schema.Date)

number

Create a route parameter that parses to a number

import { Route } from '@typed/route'

// Match numeric IDs
const userRoute = Route.literal('users').concat(Route.number('userId'))

// Match numeric values in query params
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'

// Match integer IDs
const productRoute = Route.literal('products').concat(Route.integer('productId'))

// Match page numbers
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'

// Match large numeric IDs
const largeIdRoute = Route.literal('records').concat(Route.BigInt('recordId'))

// Match timestamp values
const timeRoute = Route.literal('events').concat(Route.BigInt('timestamp'))

bigDecimal

Create a route parameter that parses to a BigDecimal

import { Route } from '@typed/route'

// Match precise decimal values
const priceRoute = Route.literal('products').concat(Route.bigDecimal('price'))

// Match coordinates
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'

// Match base64url-encoded tokens
const tokenRoute = Route.literal('verify').concat(Route.base64Url('token'))

// Match encoded data
const dataRoute = Route.literal('data').concat(Route.base64Url('payload'))

boolean

Create a route parameter that parses to a boolean

import { Route } from '@typed/route'

// Match boolean flags
const featureRoute = Route.literal('features').concat(
  Route.queryParams({
    enabled: Route.boolean('enabled')
  })
)

// Match boolean parameters
const settingRoute = Route.literal('settings').concat(Route.boolean('active'))

ulid

Create a route parameter that validates ULIDs

import { Route } from '@typed/route'

// Match ULID identifiers
const documentRoute = Route.literal('documents').concat(Route.ulid('documentId'))

// Match ULID in query params
const lookupRoute = Route.queryParams({
  id: Route.ulid('recordId')
})

uuid

Create a route parameter that validates UUIDs

import { Route } from '@typed/route'

// Match UUID identifiers
const userRoute = Route.literal('users').concat(Route.uuid('userId'))

// Match multiple UUIDs
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'

// Match date parameters
const eventRoute = Route.literal('events').concat(Route.date('eventDate'))

// Match date ranges
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'

// Match any value without naming it
const catchAllRoute = Route.literal('files').concat(Route.unnamed)

// Match multiple segments
const deepRoute = Route.literal('docs').concat(Route.unnamed.zeroOrMore())

zeroOrMore

Match zero or more occurrences of a route

import { Route } from '@typed/route'

// Match optional path segments
const filesRoute = Route.literal('files').concat(Route.param('path').zeroOrMore())

// Match multiple query parameters
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'

// Match at least one path segment
const pathRoute = Route.literal('path').concat(Route.param('segment').oneOrMore())

// Match multiple required parameters
const multiRoute = Route.literal('items').concat(
  Route.queryParams({
    id: Route.number('id').oneOrMore()
  })
)

optional

Make a route parameter optional

import { Route } from '@typed/route'

// Optional path parameter
const userRoute = Route.literal('users').concat(Route.param('userId').optional())

// Optional query parameters
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'

// Add prefix to parameter values
const userRoute = Route.number('userId').prefix('user-')
// Matches: /user-123

// Add prefix with separator
const tagRoute = Route.param('tag').prefix('tag/')
// Matches: /tag/javascript

concat

Combine multiple routes together

import { Route } from '@typed/route'

// Combine literal with parameter
const userPostRoute = Route.literal('users')
  .concat(Route.param('userId'))
  .concat(Route.literal('posts'))
  .concat(Route.param('postId'))

// Combine with query parameters
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'

// Simple query parameters
const searchRoute = Route.literal('search').concat(
  Route.queryParams({
    q: Route.param('query'),
    page: Route.number('page').optional(),
    limit: Route.number('limit').optional()
  })
)

// Complex query parameters
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'

// Add custom schema to route
const userRoute = Route.literal('users')
  .concat(Route.param('userId'))
  .pipe(Route.withSchema(Schema.Struct({
    userId: Schema.NumberFromString
  })))

// Complex schema transformation
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'))

// Decode a path
const result = await Effect.runPromise(
  Route.decode(userRoute, '/users/123')
)
// Result: { userId: 123 }

// Handle decode errors
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'))

// Encode parameters to a path
const path = await Effect.runPromise(
  Route.encode(userRoute, { userId: 123 })
)
// Result: '/users/123'

// Handle encode errors
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'

// Update schema to add validation
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'

// Transform parameters to a different shape
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) })
  ))

// Complex transformation with validation
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'

// Transform with 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'

// Add version property
const apiRoute = Route.literal('api')
  .concat(Route.param('endpoint'))
  .pipe(Route.attachPropertySignature('version', 'v1'))
// Matches to { endpoint: string; version: 'v1' }

// Add multiple properties
const userRoute = Route.literal('users')
  .concat(Route.param('userId'))
  .pipe(
    Route.attachPropertySignature('type', 'user'),
    Route.attachPropertySignature('source', 'database')
  )
// Matches to `{ userId: string; type: 'user'; source: 'database' }

addTag

Add a discriminant tag to route parameters

import { Route } from '@typed/route'

// Add type tag
const userRoute = Route.literal('users')
  .concat(Route.param('userId'))
  .pipe(Route.addTag('user'))
// Matches to { _tag: "user", userId: string }

// Use with different routes
const postRoute = Route.literal('posts')
  .concat(Route.integer('postId'))
  .pipe(Route.addTag('post'))
// Matches to { _tag: "post"; postId: number }

Utilities

sortRoutes

Sort routes by specificity for proper matching

import { Route } from '@typed/route'

// Sort routes by specificity
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'))
])

// Routes will be sorted with most specific first:
// 1. /users/settings
// 2. /users/:userId/posts
// 3. /users/:userId
// 4. /users

About

Type-safe, bi-directional, routing for Effect

Resources

Stars

Watchers

Forks

Packages

No packages published