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
- Installation
- Inspiration
- Comparison with
path-to-regexp - Features
- Route Syntax
- API
- Built-in Constraints
- Custom Constraints
- TypeScript
- Module Imports
- Error Handling
- Examples
- Design Goals
- License
pnpm add @cookbook/pathkitnpm install @cookbook/pathkityarn add @cookbook/pathkitbun add @cookbook/pathkit@cookbook/pathkit is heavily inspired by the Microsoft ASP.NET route template syntax and route constraint system.
Reference:
- ASP.NET Core Route Constraints Documentation https://learn.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-9.0#route-constraints
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.
| 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
- 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
/users/{id}/users/{id?}/files/{*path}/files/{*path?}/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.
/users/{id:int:range(1,100)}
/products/{price:decimal:min(1):max(10)}
/products/{slug:minlength(3):maxlength(50)}Compiles a route pattern into a function.
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;import { compile } from '@cookbook/pathkit';
const toUser = compile('/users/{id}');
toUser({ id: 10 });
// /users/10const toSearch = compile('/search/{term?}');
toSearch();
// /search
toSearch({ term: 'hello' });
// /search/helloconst toFile = compile('/files/{*path}');
toFile({
path: ['users', 'john', 'avatar.png'],
});
// /files/users/john/avatar.pngconst toPage = compile('/page/{type:list(home|dashboard)}');
toPage({ type: 'home' });
// /page/homeInvalid values throw:
toPage({ type: 'settings' });
// Error:
// Parameter "type" must be one of: home, dashboardChanges the route segment delimiter used for wildcard joins and route normalization.
compile('namespace.{*path}', {
delimiter: '.',
})({
path: ['frontend', 'typescript', 'routing'],
});
// namespace.frontend.typescript.routingThis is useful for non-slash route styles such as:
- dot-separated namespaces
- event routing
- CLI command patterns
- message topics
- internal identifiers
Controls route cleanup behavior after compilation.
Available values:
'all';
'duplication';
'trailing';
false;Removes duplicated delimiters and trailing delimiters.
compile('/hello//world/', {
prune: 'all',
})();
// /hello/worldRemoves only duplicated delimiters.
compile('/hello//world/', {
prune: 'duplication',
})();
// /hello/world/Removes only trailing delimiters.
compile('/hello//world/', {
prune: 'trailing',
})();
// /hello//worldDisables all cleanup behavior.
compile('/hello//world/', {
prune: false,
})();
// /hello//world/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.
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;import { match } from '@cookbook/pathkit';
const matcher = match('/users/{id:int}');
matcher('/users/42');Returns:
{
match: true,
path: '/users/42',
params: {
id: '42',
},
}matcher('/users/abc');Returns:
{
match: false,
params: null,
}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.
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: {},
}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'],
},
}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'],
},
}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.
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'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,
}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.
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'],
},
}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.
Tokenizes a route pattern into route segments.
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[];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: '',
},
],
},
];Validates route patterns before runtime usage.
declare const validateRoute: (route: string) => void;import { validateRoute } from '@cookbook/pathkit';
validateRoute('/users/{id:int}');Invalid routes throw descriptive errors.
validateRoute('/users/{id:unknown}');
// Error:
// [Constraint]: Unknown constraint type: "unknown"Constraints validate parameter values during compile() and match().
Each constraint can also provide:
verify()to validate the route constraint configuration itselftoRegExp()to generate the matching pattern used bymatch()
interface ConstraintValidation {
(paramName: string, value: string | number | boolean | undefined, params: string): void;
verify(paramName: string, params: string): void;
toRegExp(params: string): string;
}Validates that a parameter is a decimal.
{price:decimal}/products/by-price/{price:decimal}/products/1
/products/1.5
/products/42
/products/9000
/products/200.99/products/abc
/products/foo-1- Does not accept constraint parameters
Validates that a parameter is an integer.
{id:int}/users/{id:int}/users/1
/users/42
/users/9000/users/abc
/users/1.5
/users/foo-1- Does not accept constraint parameters
- Uses
\d+as its match pattern - Runtime validation is also applied during
compile()and duringmatch()when a path candidate matches the generated pattern
Validates that a parameter value matches the canonical UUID format.
{id:uuid}/users/{id:uuid}/users/550e8400-e29b-41d4-a716-446655440000
/users/00000000-0000-0000-0000-000000000000
/users/7d444840-9dc0-11d1-b245-5ffdce74fad2/users/abc
/users/550e8400e29b41d4a716446655440000
/users/550e8400-e29b-41d4-a716
/users/zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz- 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
Validates that a numeric parameter value is greater than or equal to a minimum value.
{param:min(value)}/products/{price:decimal:min(1)}/products/1
/products/9.99
/products/10/products/0
/products/0.99
/products/abc- The argument is required
- The argument must be numeric
- The comparison is inclusive
- Values are validated numerically
- Usually combined with
intordecimalto enforce numeric route matching
Validates that a numeric parameter value is less than or equal to a maximum value.
{param:max(value)}/products/{price:decimal:max(10)}/products/1
/products/9.99
/products/10/products/10.01
/products/11
/products/abc- The argument is required
- The argument must be numeric
- The comparison is inclusive
- Values are validated numerically
- Usually combined with
intordecimalto enforce numeric route matching
Validates that a numeric parameter is inside an inclusive range.
{id:range(min,max)}/users/{id:range(1,100)}/users/1
/users/50
/users/100/users/0
/users/101
/users/abcminandmaxare required- The range is inclusive
- Values are validated numerically
Validates that a parameter value has at least the specified number of characters.
{param:minlength(length)}/products/{slug:minlength(3)}/products/foo
/products/product-123
/products/águia
/products/你好世界/products/a
/products/ab- The argument is required
- The argument must be a positive integer
- Validates the parameter value length, not its numeric value
- Can be combined with
maxlengthto enforce a bounded length
Validates that a parameter value has no more than the specified number of characters.
{param:maxlength(length)}/products/{slug:maxlength(50)}/products/foo
/products/product-123
/products/águia
/products/你好世界/products/this-slug-is-too-long- The argument is required
- The argument must be a positive integer
- Validates the parameter value length, not its numeric value
- Can be combined with
minlengthto enforce a bounded length
Validates that a parameter matches one item from a pipe-separated list.
{param:list(item1|item2|item3)}/search/{type:list(view|expanded|details)}/search/view
/search/expanded
/search/details/search/grid
/search/detail- Items are separated with
| - Matching is exact
- List values are also used to generate the matcher RegExp
Validates that a parameter matches a custom regular expression.
{param:regex(pattern)}/posts/{slug:regex([a-z0-9-]+)}/posts/hello-world
/posts/post-123/posts/HelloWorld
/posts/hello_world-
The regex is used by both
compile()validation andmatch()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
regexconstraint 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 are registered globally at runtime.
A custom constraint must be created using createConstraint.
Creates a custom parameter constraint implementation.
declare const createConstraint = ({
parse,
verify,
toRegExp,
}: {
parse: (...args: Parameters<ConstraintValidation>) => void;
verify: ConstraintValidation['verify'];
toRegExp: ConstraintValidation['toRegExp'];
}) => ConstraintValidation;Implements the runtime validation logic for the parameter value.
This method is executed when the route parameter is matched and receives:
paramName: parameter namevalue: extracted parameter valueparams: constraint configuration value
Throw an error if the parameter value is invalid.
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
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.
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.
Registers or replaces a constraint.
declare const registerConstraint: (name: string, constraint: ConstraintValidation) => void;If a constraint with the same name already exists, it is replaced.
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 slugRemoves a runtime constraint.
declare const unregisterConstraint: (name: string) => void;import { unregisterConstraint } from '@cookbook/pathkit';
unregisterConstraint('slug');Checks whether a constraint exists.
declare const hasConstraint: (name: string) => boolean;import { hasConstraint } from '@cookbook/pathkit';
hasConstraint('slug');Returns a registered constraint.
declare const getConstraint: (name: string) => ConstraintValidation | undefined;import { getConstraint } from '@cookbook/pathkit';
const constraint = getConstraint('slug');Restores the built-in constraint registry and removes runtime customizations.
Useful for tests.
declare const resetConstraints: () => void;import type { RouteSegment, LiteralSegment, ParameterSegment } from '@cookbook/pathkit';import type { Constraint, ConstraintValidation } from '@cookbook/pathkit';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.
import { compile, match, tokenize, validateRoute } from '@cookbook/pathkit';import { constraints } from '@cookbook/pathkit';
constraints.registerConstraint(...);import match from '@cookbook/pathkit/match';
import compile from '@cookbook/pathkit/compile';All validation and parsing errors use standard Error instances with descriptive messages.
compile() throws when required params are missing or provided params do not satisfy constraints.
[Compile] Missing required parameter: idParameter "page" must be one of: home, dashboardmatch() 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.
Invalid route patterns and invalid constraint declarations throw.
[Tokenize] Invalid route pattern: Unexpected token[Constraint]: Unknown constraint type: "unknown"See the examples directory for complete real-world usage examples.
- Predictable behavior
- Minimal abstractions
- Runtime safety
- Composable APIs
- Framework independence
- Extensibility through constraints
- Small API surface
MIT
