Skip to content

the-cookbook/pathkit

Repository files navigation

@cookbook/pathkit

npm version npm downloads Bundle size CI

Cookbook Pathkit

A lightweight route compiler, matcher, tokenizer, and validation toolkit for JavaScript and TypeScript.

@cookbook/pathkit provides a predictable and extensible route pattern system with support for:

  • Route compilation
  • Route matching
  • Route tokenization
  • Route validation
  • Optional parameters
  • Wildcard parameters
  • Runtime constraints
  • Custom constraints
  • Custom delimiters
  • Prefix matching
  • Case-sensitive and case-insensitive matching
  • Optional parameter decoding
  • Configurable wildcard output
  • Consumed match path reporting
  • Parameter type enforcement
  • Strict match validation
  • TypeScript support
  • ESM and CommonJS

Table of Contents


Installation

pnpm add @cookbook/pathkit
npm install @cookbook/pathkit
yarn add @cookbook/pathkit
bun add @cookbook/pathkit

Inspiration

@cookbook/pathkit is heavily inspired by the Microsoft ASP.NET route template syntax and route constraint system.

Reference:

Examples:

/users/{id}
/users/{id:int}
/files/{*path}
/posts/{slug:regex([a-z0-9-]+)}

The goal is to provide a powerful and expressive route syntax for JavaScript and TypeScript applications while keeping the implementation lightweight and framework agnostic.


Comparison with path-to-regexp

Feature @cookbook/pathkit path-to-regexp
Route compilation Yes Yes
Route matching Yes Yes
Route tokenization Yes Partial
Route validation Yes No
Runtime constraint system Yes No
Built-in constraints Yes No
Custom constraints Yes Limited/custom parsing required
Optional parameters Yes Yes
Wildcard parameters Yes Yes
Configurable wildcard output Yes Partial
Prefix matching Yes Yes
Case-sensitive matching option Yes Yes
Consumed match path Yes Yes
Parameter type enforcement Yes No
Strict match validation Yes No
TypeScript-first API Yes Partial
Framework agnostic Yes Yes
Zero dependencies Yes No
Runtime-safe constraint validation Yes No

path-to-regexp focuses primarily on transforming path patterns into regular expressions.

@cookbook/pathkit focuses on complete route tooling:

  • Route parsing
  • Validation
  • Runtime-safe constraints
  • Typed route segments
  • Route compilation
  • Route matching
  • Extensibility through runtime constraint registration

Features

  • Zero dependencies
  • Small runtime footprint
  • Runtime-safe route validation
  • Extensible constraint registry
  • Functional API
  • Framework agnostic
  • SSR compatible
  • Exact and prefix route matching
  • Configurable case sensitivity
  • Configurable wildcard return format
  • ESM + CommonJS exports
  • Strong TypeScript support
  • Optional strict matching for debugging constraint failures

Route Syntax

Named Parameters

/users/{id}

Optional Parameters

/users/{id?}

Wildcard Parameters

/files/{*path}

Optional Wildcards

/files/{*path?}

Constraints

/users/{id:int}
/users/{id:uuid}
/products/{price:decimal:min(1):max(10)}
/products/{slug:minlength(3):maxlength(50)}
/posts/{slug:regex([a-z0-9-]+)}
/search/{type:list(view|expanded|details)}

Constraints can validate the parameter type, the numeric value, the value length, or a custom pattern.

Multiple Constraints

/users/{id:int:range(1,100)}
/products/{price:decimal:min(1):max(10)}
/products/{slug:minlength(3):maxlength(50)}

API

compile()

Compiles a route pattern into a function.

Signature

interface CompileOptions {
  delimiter?: string;
  prune?: 'all' | 'duplication' | 'trailing' | false;
}

type TypeOrArray<T> = T | T[];

interface CompileParams {
  [key: string]: TypeOrArray<string | number | boolean> | null | undefined;
}

declare const compile: (
  route: string,
  options?: CompileOptions,
) => (params?: CompileParams) => string;

Example

import { compile } from '@cookbook/pathkit';

const toUser = compile('/users/{id}');

toUser({ id: 10 });

// /users/10

Optional Parameters

const toSearch = compile('/search/{term?}');

toSearch();

// /search

toSearch({ term: 'hello' });

// /search/hello

Wildcards

const toFile = compile('/files/{*path}');

toFile({
  path: ['users', 'john', 'avatar.png'],
});

// /files/users/john/avatar.png

Constraints

const toPage = compile('/page/{type:list(home|dashboard)}');

toPage({ type: 'home' });

// /page/home

Invalid values throw:

toPage({ type: 'settings' });

// Error:
// Parameter "type" must be one of: home, dashboard

Compile Options

delimiter

Changes the route segment delimiter used for wildcard joins and route normalization.

compile('namespace.{*path}', {
  delimiter: '.',
})({
  path: ['frontend', 'typescript', 'routing'],
});

// namespace.frontend.typescript.routing

This is useful for non-slash route styles such as:

  • dot-separated namespaces
  • event routing
  • CLI command patterns
  • message topics
  • internal identifiers

prune

Controls route cleanup behavior after compilation.

Available values:

'all';
'duplication';
'trailing';
false;

'all'

Removes duplicated delimiters and trailing delimiters.

compile('/hello//world/', {
  prune: 'all',
})();

// /hello/world

'duplication'

Removes only duplicated delimiters.

compile('/hello//world/', {
  prune: 'duplication',
})();

// /hello/world/

'trailing'

Removes only trailing delimiters.

compile('/hello//world/', {
  prune: 'trailing',
})();

// /hello//world

false

Disables all cleanup behavior.

compile('/hello//world/', {
  prune: false,
})();

// /hello//world/

match()

Matches a route pattern against a path.

By default, match() is router-safe: constraint validation failures return a failed match instead of throwing. This makes it suitable for trying multiple route candidates.

Use strict: true when you want constraint validation errors to be thrown for debugging or development tooling.

Successful matches include the consumed path. For exact matches, this is the full matched path. For prefix matches with end: false, this is only the consumed prefix. This is useful for router-style integrations where a matched prefix must be stripped before continuing to nested middleware or child routes.

Signature

type DecodeParam = (value: string) => string;

type WildcardFormat = 'string' | 'array';

interface MatchOptions {
  delimiter?: string;
  trailing?: boolean;
  strict?: boolean;
  sensitive?: boolean;
  end?: boolean;
  wildcardFormat?: WildcardFormat;
  decode?: boolean | DecodeParam;
}

type MatchedParam = Record<string, string | string[] | undefined>;

type MatchResult =
  | {
      match: true;
      path: string;
      params: MatchedParam;
    }
  | {
      match: false;
      params: null;
    };

declare const match: (route: string, options?: MatchOptions) => (path: string) => MatchResult;

Example

import { match } from '@cookbook/pathkit';

const matcher = match('/users/{id:int}');

matcher('/users/42');

Returns:

{
  match: true,
  path: '/users/42',
  params: {
    id: '42',
  },
}

Failed Match

matcher('/users/abc');

Returns:

{
  match: false,
  params: null,
}

Strict Match

By default, invalid constrained values return a failed match:

const matcher = match('/users/{id:int}');

matcher('/users/abc');

Returns:

{
  match: false,
  params: null,
}

Enable strict mode to throw constraint validation errors:

const strictMatcher = match('/users/{id:int}', {
  strict: true,
});

strictMatcher('/users/abc');

Throws:

[Constraint] Parameter "id" must be a number, instead got 'string'

This is useful for development tools, tests, debugging, and cases where an invalid constrained value should be treated as an application error instead of a non-match.


Optional Parameters

const matcher = match('/search/{term?}');

matcher('/search');

Returns:

{
  match: true,
  path: '/search',
  params: {
    term: undefined,
  },
}

Optional parameters next to a previous literal delimiter may be omitted cleanly:

match('/product/{slug?}')('/product');

Returns:

{
  match: true,
  path: '/product',
  params: {},
}

Wildcards

const matcher = match('/files/{*path}');

matcher('/files/users/john/avatar.png');

Returns:

{
  match: true,
  path: '/files/users/john/avatar.png',
  params: {
    path: 'users/john/avatar.png',
  },
}

Use wildcardFormat: 'array' to return wildcard values as path segments:

const matcher = match('/files/{*path}', {
  wildcardFormat: 'array',
});

matcher('/files/users/john/avatar.png');

Returns:

{
  match: true,
  path: '/files/users/john/avatar.png',
  params: {
    path: ['users', 'john', 'avatar.png'],
  },
}

Match Options

delimiter

Supports non-slash route styles.

const matcher = match('.users.{id}', {
  delimiter: '.',
});

matcher('.users.10');

Returns:

{
  match: true,
  path: '.users.10',
  params: {
    id: '10',
  },
}

The delimiter is also used when splitting wildcard params with wildcardFormat: 'array'.

const matcher = match('.files.{*path}', {
  delimiter: '.',
  wildcardFormat: 'array',
});

matcher('.files.docs.guides.readme');

Returns:

{
  match: true,
  path: '.files.docs.guides.readme',
  params: {
    path: ['docs', 'guides', 'readme'],
  },
}

trailing

Controls trailing delimiter matching.

Default:

trailing: true;

When trailing is enabled, a final delimiter is accepted:

match('/hello/{name}')('/hello/world/');

Returns:

{
  match: true,
  path: '/hello/world/',
  params: {
    name: 'world',
  },
}

When trailing is disabled, the same path fails:

match('/hello/{name}', {
  trailing: false,
})('/hello/world/');

Returns:

{
  match: false,
  params: null,
}

trailing only controls a final delimiter. It does not allow extra path segments.


strict

Controls whether constraint validation errors are thrown.

Default:

strict: false;

When strict is disabled, constraint validation failures return:

{
  match: false,
  params: null,
}

When strict is enabled, constraint validation failures are thrown:

match('/users/{id:int}', {
  strict: true,
})('/users/abc');

Throws:

[Constraint] Parameter "id" must be a number, instead got 'string'

sensitive

Controls case-sensitive matching.

Default:

sensitive: false;

By default, matching is case-insensitive:

match('/Users/{id}')('/users/42');

Returns:

{
  match: true,
  path: '/users/42',
  params: {
    id: '42',
  },
}

Enable sensitive to require exact casing:

match('/Users/{id}', {
  sensitive: true,
})('/users/42');

Returns:

{
  match: false,
  params: null,
}

end

Controls whether matching must cover the full pathname or may stop at a path segment boundary.

Default:

end: true;

When end is enabled, the route must match the complete path:

match('/api')('/api/users');

Returns:

{
  match: false,
  params: null,
}

When end is disabled, the route can match a path prefix:

match('/api', {
  end: false,
})('/api/users');

Returns:

{
  match: true,
  path: '/api',
  params: {},
}

Prefix matching respects route delimiter boundaries:

match('/api', {
  end: false,
})('/apix/users');

Returns:

{
  match: false,
  params: null,
}

This is useful for middleware mounting and nested router integrations.


wildcardFormat

Controls whether wildcard params are returned as a single string or as an array of segments.

Default:

wildcardFormat: 'string';

String output:

match('/files/{*path}')('/files/docs/guides/readme');

Returns:

{
  match: true,
  path: '/files/docs/guides/readme',
  params: {
    path: 'docs/guides/readme',
  },
}

Array output:

match('/files/{*path}', {
  wildcardFormat: 'array',
})('/files/docs/guides/readme');

Returns:

{
  match: true,
  path: '/files/docs/guides/readme',
  params: {
    path: ['docs', 'guides', 'readme'],
  },
}

With a custom delimiter:

match('.files.{*path}', {
  delimiter: '.',
  wildcardFormat: 'array',
})('.files.docs.guides.readme');

Returns:

{
  match: true,
  path: '.files.docs.guides.readme',
  params: {
    path: ['docs', 'guides', 'readme'],
  },
}

decode

Controls whether matched params are decoded.

Default:

decode: false;

By default, params are returned exactly as captured from the path:

match('/hello/{name}')('/hello/John%20Doe');

Returns:

{
  match: true,
  path: '/hello/John%Doe',
  params: {
    name: 'John%20Doe',
  },
}

Use decode: true to decode params with decodeURIComponent:

match('/hello/{name}', {
  decode: true,
})('/hello/John%20Doe');

Returns:

{
  match: true,
  path: '/hello/John%20Doe',
  params: {
    name: 'John Doe',
  },
}

Use a custom decoder function for framework-specific behavior:

match('/hello/{name}', {
  decode: (value) => value.replaceAll('-', ' '),
})('/hello/John-Doe');

Returns:

{
  match: true,
  path: '/hello/John-Doe',
  params: {
    name: 'John Doe',
  },
}

When wildcardFormat: 'array' is used, wildcard values are split first and decoded segment by segment. This preserves encoded delimiters inside a segment.

match('/files/{*path}', {
  decode: true,
  wildcardFormat: 'array',
})('/files/a%2Fb/c%20d');

Returns:

{
  match: true,
  path: '/files/a%2Fb/c%20d',
  params: {
    path: ['a/b', 'c d'],
  },
}

Decode errors are thrown and are not converted into failed matches, even when strict is disabled.


tokenize()

Tokenizes a route pattern into route segments.

Signature

type TokenType = 'literal' | 'parameter';

interface Constraint {
  type: string;
  params: string;
}

interface LiteralSegment {
  type: 'literal';
  value: string;
}

interface ParameterSegment {
  type: 'parameter';
  name: string;
  wildcard: boolean;
  optional: boolean;
  constraints: Constraint[];
}

type RouteSegment = LiteralSegment | ParameterSegment;

declare const tokenize: (route: string) => RouteSegment[];

Example

import { tokenize } from '@cookbook/pathkit';

tokenize('/users/{id:int}');

Returns:

[
  {
    type: 'literal',
    value: '/users/',
  },
  {
    type: 'parameter',
    name: 'id',
    wildcard: false,
    optional: false,
    constraints: [
      {
        type: 'int',
        params: '',
      },
    ],
  },
];

validateRoute()

Validates route patterns before runtime usage.

Signature

declare const validateRoute: (route: string) => void;

Example

import { validateRoute } from '@cookbook/pathkit';

validateRoute('/users/{id:int}');

Invalid routes throw descriptive errors.

validateRoute('/users/{id:unknown}');

// Error:
// [Constraint]: Unknown constraint type: "unknown"

Built-in Constraints

Constraints validate parameter values during compile() and match().

Each constraint can also provide:

  • verify() to validate the route constraint configuration itself
  • toRegExp() to generate the matching pattern used by match()

ConstraintValidation API

interface ConstraintValidation {
  (paramName: string, value: string | number | boolean | undefined, params: string): void;

  verify(paramName: string, params: string): void;

  toRegExp(params: string): string;
}

decimal

Validates that a parameter is a decimal.

Syntax

{price:decimal}

Example

/products/by-price/{price:decimal}

Valid

/products/1
/products/1.5
/products/42
/products/9000
/products/200.99

Invalid

/products/abc
/products/foo-1

Notes

  • Does not accept constraint parameters

int

Validates that a parameter is an integer.

Syntax

{id:int}

Example

/users/{id:int}

Valid

/users/1
/users/42
/users/9000

Invalid

/users/abc
/users/1.5
/users/foo-1

Notes

  • Does not accept constraint parameters
  • Uses \d+ as its match pattern
  • Runtime validation is also applied during compile() and during match() when a path candidate matches the generated pattern

uuid

Validates that a parameter value matches the canonical UUID format.

Syntax

{id:uuid}

Example

/users/{id:uuid}

Valid

/users/550e8400-e29b-41d4-a716-446655440000
/users/00000000-0000-0000-0000-000000000000
/users/7d444840-9dc0-11d1-b245-5ffdce74fad2

Invalid

/users/abc
/users/550e8400e29b41d4a716446655440000
/users/550e8400-e29b-41d4-a716
/users/zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz

Notes

  • Does not accept constraint parameters
  • Validates the standard hyphenated UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
  • Matches UUID-like values such as UUID v1, v3, v4, and v5 when they use the canonical format
  • Does not enforce a specific UUID version

min

Validates that a numeric parameter value is greater than or equal to a minimum value.

Syntax

{param:min(value)}

Example

/products/{price:decimal:min(1)}

Valid

/products/1
/products/9.99
/products/10

Invalid

/products/0
/products/0.99
/products/abc

Notes

  • The argument is required
  • The argument must be numeric
  • The comparison is inclusive
  • Values are validated numerically
  • Usually combined with int or decimal to enforce numeric route matching

max

Validates that a numeric parameter value is less than or equal to a maximum value.

Syntax

{param:max(value)}

Example

/products/{price:decimal:max(10)}

Valid

/products/1
/products/9.99
/products/10

Invalid

/products/10.01
/products/11
/products/abc

Notes

  • The argument is required
  • The argument must be numeric
  • The comparison is inclusive
  • Values are validated numerically
  • Usually combined with int or decimal to enforce numeric route matching

range

Validates that a numeric parameter is inside an inclusive range.

Syntax

{id:range(min,max)}

Example

/users/{id:range(1,100)}

Valid

/users/1
/users/50
/users/100

Invalid

/users/0
/users/101
/users/abc

Notes

  • min and max are required
  • The range is inclusive
  • Values are validated numerically

minlength

Validates that a parameter value has at least the specified number of characters.

Syntax

{param:minlength(length)}

Example

/products/{slug:minlength(3)}

Valid

/products/foo
/products/product-123
/products/águia
/products/你好世界

Invalid

/products/a
/products/ab

Notes

  • The argument is required
  • The argument must be a positive integer
  • Validates the parameter value length, not its numeric value
  • Can be combined with maxlength to enforce a bounded length

maxlength

Validates that a parameter value has no more than the specified number of characters.

Syntax

{param:maxlength(length)}

Example

/products/{slug:maxlength(50)}

Valid

/products/foo
/products/product-123
/products/águia
/products/你好世界

Invalid

/products/this-slug-is-too-long

Notes

  • The argument is required
  • The argument must be a positive integer
  • Validates the parameter value length, not its numeric value
  • Can be combined with minlength to enforce a bounded length

list

Validates that a parameter matches one item from a pipe-separated list.

Syntax

{param:list(item1|item2|item3)}

Example

/search/{type:list(view|expanded|details)}

Valid

/search/view
/search/expanded
/search/details

Invalid

/search/grid
/search/detail

Notes

  • Items are separated with |
  • Matching is exact
  • List values are also used to generate the matcher RegExp

regex

Validates that a parameter matches a custom regular expression.

Syntax

{param:regex(pattern)}

Example

/posts/{slug:regex([a-z0-9-]+)}

Valid

/posts/hello-world
/posts/post-123

Invalid

/posts/HelloWorld
/posts/hello_world

Notes

  • The regex is used by both compile() validation and match() route matching

  • Do not include route delimiters unless the parameter is intended to match them

  • For cross-segment matching, use a wildcard parameter instead

  • The regex constraint pattern should be provided as a raw regex source, without the conventional JavaScript regex delimiters /.../.

    Example:

    /posts/{slug:regex(/[a-z0-9-]+/)} // ERROR
    /posts/{slug:regex([a-z0-9-]+)} // CORRECT

Custom Constraints

Custom constraints are registered globally at runtime.

A custom constraint must be created using createConstraint.

createConstraint

Creates a custom parameter constraint implementation.

Signature

declare const createConstraint = ({
  parse,
  verify,
  toRegExp,
}: {
  parse: (...args: Parameters<ConstraintValidation>) => void;
  verify: ConstraintValidation['verify'];
  toRegExp: ConstraintValidation['toRegExp'];
}) => ConstraintValidation;

Methods

parse

Implements the runtime validation logic for the parameter value.

This method is executed when the route parameter is matched and receives:

  • paramName: parameter name
  • value: extracted parameter value
  • params: constraint configuration value

Throw an error if the parameter value is invalid.

verify

Validates the constraint configuration itself.

Use this method to ensure the constraint declaration is valid and correctly formatted before parse is executed.

Typical use cases include:

  • validating constraint arguments
  • rejecting unsupported parameters
  • validating parameter formatting

toRegExp

Returns the regular expression pattern used to extract and match the parameter value from the route.

The returned value must be a valid regex pattern string without delimiters.

Example

import { createConstraint } from '@cookbook/pathkit';

const slug = createConstraint({
  parse: (paramName, value) => {
    if (typeof value !== 'string') {
      throw new Error(`Parameter "${paramName}" must be a string`);
    }

    if (!/^[a-z0-9-]+$/.test(value)) {
      throw new Error(`Parameter "${paramName}" must be a valid slug`);
    }
  },

  verify: (paramName, params) => {
    if (params.trim().length) {
      throw new Error(
        `[Constraint] Constraint 'slug' declared for '${paramName}' does not accept parameters, ` +
          `but received '${params}'.`,
      );
    }
  },

  toRegExp: () => '[a-z0-9-]+',
});

Note: verify is called automatically before parse is executed.


registerConstraint()

Registers or replaces a constraint.

Signature

declare const registerConstraint: (name: string, constraint: ConstraintValidation) => void;

If a constraint with the same name already exists, it is replaced.

Example

import { match, registerConstraint } from '@cookbook/pathkit';

registerConstraint('slug', slug);

const matcher = match('/posts/{slug:slug}');

matcher('/posts/hello-world');

Returns:

{
  match: true,
  path: '/posts/hello-world',
  params: {
    slug: 'hello-world',
  },
}

Invalid values return a failed match by default:

matcher('/posts/heiß');

Returns:

{
  match: false,
  params: null,
}

Use strict mode to throw the custom constraint error:

const strictMatcher = match('/posts/{slug:slug}', {
  strict: true,
});

strictMatcher('/posts/heiß');

Throws:

Parameter "slug" must be a valid slug

unregisterConstraint()

Removes a runtime constraint.

Signature

declare const unregisterConstraint: (name: string) => void;

Example

import { unregisterConstraint } from '@cookbook/pathkit';

unregisterConstraint('slug');

hasConstraint()

Checks whether a constraint exists.

Signature

declare const hasConstraint: (name: string) => boolean;

Example

import { hasConstraint } from '@cookbook/pathkit';

hasConstraint('slug');

getConstraint()

Returns a registered constraint.

Signature

declare const getConstraint: (name: string) => ConstraintValidation | undefined;

Example

import { getConstraint } from '@cookbook/pathkit';

const constraint = getConstraint('slug');

resetConstraints()

Restores the built-in constraint registry and removes runtime customizations.

Useful for tests.

Signature

declare const resetConstraints: () => void;

TypeScript

Route Segments

import type { RouteSegment, LiteralSegment, ParameterSegment } from '@cookbook/pathkit';

Constraints

import type { Constraint, ConstraintValidation } from '@cookbook/pathkit';

Match Results

import type { MatchedParam, MatchResult, MatchOptions, WildcardFormat } from '@cookbook/pathkit';

MatchedParam values can be string, string[], or undefined. Wildcard params use string[] only when wildcardFormat: 'array' is enabled.

Successful MatchResult values include the consumed path. Failed match results include params: null and do not include path.


Module Imports

Root Import

import { compile, match, tokenize, validateRoute } from '@cookbook/pathkit';

Constraint Namespace

import { constraints } from '@cookbook/pathkit';

constraints.registerConstraint(...);

Deep Imports

import match from '@cookbook/pathkit/match';
import compile from '@cookbook/pathkit/compile';

Error Handling

All validation and parsing errors use standard Error instances with descriptive messages.

compile()

compile() throws when required params are missing or provided params do not satisfy constraints.

[Compile] Missing required parameter: id
Parameter "page" must be one of: home, dashboard

match()

match() returns failed matches by default when a path does not match the route or does not satisfy route constraints.

{
  match: false,
  params: null,
}

Successful matches include the consumed path.

{
  match: true,
  path: '/users/42',
  params: {
    id: '42',
  },
}

With strict: true, constraint validation errors are thrown instead of being converted into failed matches.

[Constraint] Parameter "id" must be a number, instead got 'string'

Decode errors are always thrown when decode is enabled. They are not converted into failed matches, because malformed encoded path values are input errors rather than route misses.

tokenize() / validateRoute()

Invalid route patterns and invalid constraint declarations throw.

[Tokenize] Invalid route pattern: Unexpected token
[Constraint]: Unknown constraint type: "unknown"

Examples

See the examples directory for complete real-world usage examples.


Design Goals

  • Predictable behavior
  • Minimal abstractions
  • Runtime safety
  • Composable APIs
  • Framework independence
  • Extensibility through constraints
  • Small API surface

License

MIT