@cookbook/urlkit
v2.1.0
Published
Framework-agnostic typed URL contract library for parsing, validating, normalizing, matching, and building URL state.
Maintainers
Keywords
Readme
@cookbook/urlkit
Framework-agnostic URL contracts for parsing, validating, normalizing, matching, and building URL state.
URLKit defines reusable URL contracts once, then uses them to parse, validate, normalize, match, and build path params, search params, hash fragments, requests, hrefs, filters, and other URL-driven application state wherever URLs are consumed.
Without URLKit
const rawUrl = new URL(
'/articles/42?page=2&tag=ts&tag=urlkit&sort=popular#comments',
'https://your-app.com',
);
const id = Number(rawUrl.pathname.split('/').at(-1));
const pageValues = rawUrl.searchParams.getAll('page');
const tagValues = rawUrl.searchParams.getAll('tag');
const sortValues = rawUrl.searchParams.getAll('sort');
const hash = rawUrl.hash ? rawUrl.hash.slice(1) : undefined;
if (!Number.isInteger(id)) {
throw new Error('Invalid article id.');
}
if (pageValues.length > 1) {
throw new Error('Expected only one page value.');
}
if (sortValues.length > 1) {
throw new Error('Expected only one sort value.');
}
const page = Number(pageValues[0] ?? '1');
const sort = sortValues[0] ?? 'newest';
if (!Number.isInteger(page)) {
throw new Error('Invalid page.');
}
if (sort !== 'newest' && sort !== 'popular') {
throw new Error('Invalid sort.');
}
if (hash !== undefined && hash !== 'comments' && hash !== 'share') {
throw new Error('Invalid hash.');
}
...
With URLKit
const ArticleUrl = url({
path: '/articles/{id:int}',
search: {
page: int().default(1),
tag: array(string()).optional(),
sort: enumOf(['newest', 'popular']).default('newest'),
},
hash: enumOf(['comments', 'share']).optional(),
});
const { params, search, hash } = ArticleUrl.parse('/articles/42?page=2&tag=ts&tag=urlkit&sort=popular#comments');
// params.id: number
// search.page: number
// search.tag: string[] | undefined
// search.sort: "newest" | "popular"
// hash: "comments" | "share" | undefined
...
Define the URL shape once. URLKit validates the path, parses params, applies defaults, handles repeated query values, validates the hash, and returns typed URL state you can use right away.
Documentation
Real-world framework examples
Full integration examples are available under examples/integrations. They show the same product catalog contracts used with Next.js, Express, Hono, Fastify, React Router, Remix, and TanStack Router, including local Express/Hono/Fastify middleware wrappers that accept a URLKit contract plus options.
Table of contents
- Documentation
- Real-world framework examples
- Installation
- Quick start
- Why URLKit?
- Package exports
- Core concepts
- Path-based URL contracts
- Pathless URL contracts
- Search params
- Unknown search params
- Defaults behavior
- Dates
- Object search
- Hash
- Safe APIs
- Request parsing
- Static descriptors
- Router-runtime usage
- Error handling
- TypeScript inference
- Framework boundary
- Testing and development
Installation
pnpm add @cookbook/urlkit
npm install @cookbook/urlkit
yarn add @cookbook/urlkitQuick start
import { int, string, url } from '@cookbook/urlkit';
const UserUrl = url({
path: '/users/{id:int}',
search: {
tab: string().default('profile'),
page: int().default(1),
},
});
const state = UserUrl.parse('/users/42?tab=settings&page=2');
// state.params.id: number
// state.search.tab: string
const href = UserUrl.build({
params: { id: 42 },
search: { tab: 'settings', page: 2 },
});
// '/users/42?tab=settings&page=2'Why URLKit?
URLs usually cross boundaries as strings, but application code wants typed state. URLKit gives you one reusable contract for:
- parsing serialized URLs into typed state
- normalizing structured params/search/hash from framework or server inputs
- building canonical URLs from typed state
- validating and matching URLs without routing dependencies
- sharing the same URL contract across browser, server, edge, router, CLI, and test environments
Package exports
| Import path | Purpose |
| --------------------------------- | ---------------------------------------------------------------------------- |
| @cookbook/urlkit | Runtime URL contracts, schema builders, public contracts, and UrlKitError. |
| @cookbook/urlkit/static | Static descriptor compilers for router-compatible analyzable descriptors. |
| @cookbook/urlkit/router-runtime | Framework-agnostic runtime helpers for router packages. |
import { url, search, hash, string, int, enumOf } from '@cookbook/urlkit';
import { compileStaticUrl } from '@cookbook/urlkit/static';
import { createRouteUrlContract } from '@cookbook/urlkit/router-runtime';Core concepts
parse, normalize, build, and match
| Method | Input | Purpose |
| ----------- | --------------------------------------- | ------------------------------------------------------------------------- |
| parse | Serialized URL input: string or URL | Parse and validate a URL string/object into typed UrlState. |
| normalize | Structured URL state | Validate/coerce params, search, and hash from application/framework data. |
| build | Typed URL state | Serialize state to a canonical URL string. |
| match | Serialized URL input: string or URL | Return true/false for ordinary URL validation. |
parse intentionally does not accept structured objects. Use normalize for structured state.
UrlState
Parsed and normalized state always includes pathname, params, search, and hash. Optional hashes are represented as undefined; the hash property itself is still present.
interface UrlState<Pathname, Params, Search, Hash> {
readonly pathname: Pathname;
readonly params: Params;
readonly search: Search;
readonly hash: Hash;
readonly unknownSearch?: UnknownSearchParams;
}Preserved unknown search params live in state.unknownSearch, not in state.search.
Path-based vs pathless contracts
| Mode | How to create it | Path behavior | Build behavior |
| ---------- | ---------------- | ------------------------------------------- | ------------------------------------------------------------------- |
| Path-based | Provide path | Validates pathnames and infers path params. | Builds from params. |
| Pathless | Omit path | Accepts any pathname. | Without pathname, returns a suffix like ?page=2 or #comments. |
Path-based URL contracts
Path-based contracts use @cookbook/pathkit for path pattern matching/building. URLKit adds typed URL state around those paths.
import { url } from '@cookbook/urlkit';
const ArticleUrl = url({
path: '/articles/{slug:regex([a-z0-9-]+)}',
});
const state = ArticleUrl.parse('/articles/post-1');
// state.pathname: `/articles/${string}`
// state.params.slug: string
ArticleUrl.build({ params: { slug: 'post-1' } });
// '/articles/post-1'
ArticleUrl.match('/articles/post-1');
// true
ArticleUrl.match('/users/post-1');
// falsePath-based build input uses params, not pathname:
ArticleUrl.build({ params: { slug: 'post-1' } });
// Invalid for path-based contracts:
// ArticleUrl.build({ pathname: '/articles/post-1' });Path params are inferred from the pattern. PathKit owns path constraint syntax, matching, and runtime validation. URLKit delegates those concerns to PathKit and only reads PathKit-compatible constraint chains to infer/coerce parsed params.
Built-in PathKit constraint inference in URLKit:
| Constraint | URLKit parsed param type | Notes |
| ------------------- | ------------------------ | -------------------------------------------- |
| int | number | Integer values. |
| decimal | number | Decimal numeric values. |
| min(value) | number | Inclusive numeric minimum. |
| max(value) | number | Inclusive numeric maximum. |
| range(min,max) | number | Inclusive numeric range. |
| uuid | string | Canonical hyphenated UUID values. |
| minlength(length) | string | Minimum string length. |
| maxlength(length) | string | Maximum string length. |
| list(a\| b\| c) | string | Exact pipe-separated list values. |
| regex(pattern) | string | Raw regex source without /.../ delimiters. |
Custom PathKit constraints infer string unless a numeric built-in constraint also appears in the chain.
const UserUrl = url({ path: '/users/{id:int}' });
const user = UserUrl.parse('/users/42');
// user.params.id: numberNumeric constraints can be used alone or chained with other constraints:
const ProductUrl = url({ path: '/products/{id:min(1):max(10)?}' });
ProductUrl.parse('/products/2.5').params.id;
// number | undefined
ProductUrl.build({});
// '/products'The regex constraint receives raw regex source, not a JavaScript regex literal:
/posts/{slug:regex(/[a-z0-9-]+/)} // ERROR
/posts/{slug:regex([a-z0-9-]+)} // CORRECTInside TypeScript string literals, escape backslashes as needed:
url({ path: '/scores/{id:regex(\\d):min(1)}' });Path match options
URLKit uses PathKit for path matching. You can pass path match options to parse, safeParse, parseRequest, safeParseRequest, match, and parsePathname. They do not affect normalize or build.
URLKit always uses / as the path delimiter.
| Option | Default | What it does |
| ---------------- | ---------- | ------------------------------------------------------- |
| trailing | true | Allows one final /. |
| sensitive | false | Matches paths without checking letter case. |
| strict | false | Lets match return false for path constraint misses. |
| end | true | Requires the whole pathname to match. |
| wildcardFormat | 'string' | Returns wildcard params as one string. |
| decode | false | Keeps path params raw. |
Set path match options on the contract when you want the same behavior everywhere:
const ApiUrl = url(
{ path: '/api' },
{
pathMatch: {
end: false,
},
},
);
ApiUrl.parse('/api/users').pathname;
// '/api'Per-call options override contract options:
ApiUrl.match('/api/users', { end: true });
// falseUse end: false to match only the start of a pathname. When parse uses end: false, state.pathname is the part matched by the contract.
const parsed = ApiUrl.parse('/api/users?page=2', { end: false });
parsed.pathname;
// '/api'Wildcard params are strings by default:
const FileUrl = url({ path: '/files/{*path}' });
FileUrl.parse('/files/docs/readme').params;
// { path: 'docs/readme' }Use wildcardFormat: 'array' to get path segments:
FileUrl.parse('/files/docs/readme', { wildcardFormat: 'array' }).params;
// { path: ['docs', 'readme'] }Path params are raw by default. Use decode: true to decode them:
const UserUrl = url({ path: '/users/{name}' });
UserUrl.parse('/users/John%20Doe').params;
// { name: 'John%20Doe' }
UserUrl.parse('/users/John%20Doe', { decode: true }).params;
// { name: 'John Doe' }Custom path constraints
URLKit re-exports PathKit's createConstraint and provides global registration helpers for reusable path constraints. Custom constraints infer string params by default unless chained with a numeric constraint. Built-in int, decimal, range(...), min(...), and max(...) infer number. When a PathKit constraint rejects a value, URLKit wraps it as UrlKitError with code: 'invalid-param' and preserves the original PathKit error in error.cause.
import { createConstraint, registerPathConstraint, url } from '@cookbook/urlkit';
const slug = createConstraint({
parse(paramName, value) {
if (!/^[a-z0-9-]+$/.test(String(value))) {
throw new Error(`Path parameter "${paramName}" must be a slug.`);
}
},
verify(paramName, params) {
if (params.trim()) {
throw new Error(`Constraint "slug" declared for "${paramName}" does not accept arguments.`);
}
},
toRegExp() {
return '[a-z0-9-]+';
},
});
registerPathConstraint('slug', slug);
const ArticleUrl = url({
path: '/articles/{slug:slug}',
});
ArticleUrl.parse('/articles/hello-world').params.slug;
// stringUse per-contract registration when a constraint should be local to a contract or test:
const ArticleUrl = url({ path: '/articles/{slug:slug}' }, { pathConstraints: { slug } });Pathless URL contracts
Pathless contracts validate search/hash independently of pathname. pattern is undefined, params is {}, and parse preserves the input pathname.
import { int, url } from '@cookbook/urlkit';
const FiltersUrl = url({
search: {
page: int().default(1),
},
});
FiltersUrl.build({
search: { page: 2 },
});
// '?page=2'
FiltersUrl.build({
pathname: '/products',
search: { page: 2 },
});
// '/products?page=2'
FiltersUrl.parse('/anything?page=3').pathname;
// '/anything'Search-only helper
import { int, search, string } from '@cookbook/urlkit';
const ProductSearch = search({
category: string().optional(),
page: int().default(1),
});
ProductSearch.build({ search: { page: 2 } });
// '?page=2'Hash-only helper
import { enumOf, hash } from '@cookbook/urlkit';
const DocsHash = hash(enumOf(['intro', 'api']).optional());
DocsHash.parse('/docs#api').hash;
// 'api'
DocsHash.build({ hash: 'api' });
// '#api'Search params
Runtime search schemas use builders from the main entry.
import { array, boolean, enumOf, int, number, string, url } from '@cookbook/urlkit';
const SearchUrl = url({
path: '/search',
search: {
q: string(),
page: int().default(1),
score: number().optional(),
active: boolean().optional(),
tags: array(string()).optional(),
sort: enumOf(['newest', 'popular']).default('newest'),
},
});
SearchUrl.parse('/search?q=url&page=2&active=true&tags=ts&tags=router');
SearchUrl.build({
search: {
q: 'url',
page: 2,
active: true,
tags: ['ts', 'router'],
sort: 'newest',
},
});
// '/search?q=url&page=2&active=true&tags=ts&tags=router&sort=newest'Arrays parse and serialize as repeated params by default. Pass { arrayFormat: 'comma' } to url(...), parse, safeParse, parseRequest, safeParseRequest, match, build, parseSearch, or buildSearch to use comma-separated arrays. Per-call options override the contract-level default, so { arrayFormat: 'repeat' } can force repeated keys on a comma-configured contract.
const TagUrl = url(
{
path: '/search',
search: {
tags: array(string()).optional(),
},
},
{ arrayFormat: 'comma' },
);
TagUrl.parse('/search?tags=ts%2Crouter').search.tags;
// ['ts', 'router']
TagUrl.build({ search: { tags: ['ts', 'router'] } });
// '/search?tags=ts%2Crouter'
TagUrl.build({ search: { tags: ['ts', 'router'] } }, { arrayFormat: 'repeat' });
// '/search?tags=ts&tags=router'Unknown search params
Unknown search params default to strip.
| Behavior | Result |
| ---------- | ----------------------------------------------- |
| strip | Remove unknown params from typed state. |
| preserve | Put unknown params in state.unknownSearch. |
| error | Throw UrlKitError with code invalid-search. |
const QueryUrl = url({
search: {
q: string(),
},
});
QueryUrl.parse('/search?q=router&debug=true');
// search: { q: 'router' }
QueryUrl.parse('/search?q=router&debug=true', { unknownSearch: 'preserve' });
// search: { q: 'router' }
// unknownSearch: { debug: 'true' }
QueryUrl.safeParse('/search?q=router&debug=true', { unknownSearch: 'error' });
// { success: false, error: UrlKitError }Defaults behavior
parse and normalize always apply defaults. build serializes the values it receives and includes defaults by default.
const Paging = search({
page: int().default(1),
});
Paging.parse('/products').search;
// { page: 1 }
Paging.build({ search: { page: 1 } });
// '?page=1'
Paging.build({ search: { page: 1 } }, { defaults: 'omit' });
// ''Default omission compares normalized values, so defaults are compared after the same validation/coercion rules used by the contract.
Dates
import { date, dateTime, search } from '@cookbook/urlkit';
const Reports = search({
day: date(),
at: dateTime().optional(),
createdAt: date({ format: 'unix-seconds' }).optional(),
updatedAt: date({ format: 'unix-ms' }).optional(),
});| Builder | Serialized format |
| --------------------------------------------- | -------------------------------------- |
| date() | Date-only YYYY-MM-DD. |
| dateTime() | Strict UTC YYYY-MM-DDTHH:mm:ss.sssZ. |
| date({ format: 'dd-MM-yyyy' }) | Strict custom date format string. |
| dateTime({ format: 'dd-MM-yyyy HH:mm:ss' }) | Strict custom date-time format string. |
| date({ format: { parse, serialize } }) | Custom runtime date codec. |
| dateTime({ format: { parse, serialize } }) | Custom runtime date-time codec. |
| date({ format: 'unix-seconds' }) | Finite integer seconds. |
| date({ format: 'unix-ms' }) | Finite integer milliseconds. |
Custom date and date-time format strings are available in runtime-builder schemas and static router descriptors. Supported tokens are yyyy, MM, dd, HH, mm, ss, and SSS. Static date defaults use serialized values, not Date instances. Static descriptors may use format strings, but not custom { parse, serialize } codecs.
const CustomDate = search({
from: date({ format: 'dd-MM-yyyy' }),
at: dateTime({ format: 'dd-MM-yyyy HH:mm:ss' }).optional(),
});
CustomDate.build({
search: {
from: new Date('2026-06-02T00:00:00.000Z'),
at: new Date('2026-06-02T12:30:05.000Z'),
},
});
// '?from=02-06-2026&at=02-06-2026+12%3A30%3A05'Object search
object(...) hydrates declared object fields from dotted search keys. Raw search parsing without a schema remains flat.
import { boolean, object, string, search } from '@cookbook/urlkit';
const Filters = search({
filter: object({
role: string().optional(),
active: boolean().optional(),
'user.name': string().optional(),
}),
});
Filters.build({
search: {
filter: {
role: 'admin',
active: true,
'user.name': 'Ada',
},
},
});
// '?filter.role=admin&filter.active=true&filter.user%7E1name=Ada'Object search key rules:
| Rule | Behavior |
| ---------------- | ----------------------------------------------------------------------------- |
| Declared objects | Only fields declared with object(...) hydrate nested object values. |
| Dot notation | Object fields serialize as field.child=value. |
| ~ escaping | ~ becomes ~0. |
| . escaping | . becomes ~1. |
| URL encoding | Happens after object-key segment escaping. |
| Collisions | Ambiguous object/scalar collisions throw UrlKitError with invalid-search. |
Hash
Hashes support optional, required, enum, and defaulted values. Parsed and normalized UrlState always includes a hash property.
import { enumOf, hash, string, url } from '@cookbook/urlkit';
const OptionalHash = hash(enumOf(['intro', 'api']).optional());
OptionalHash.parse('/docs#api').hash;
// 'api'
const RequiredHash = hash(string().required());
RequiredHash.parse('/docs#overview').hash;
// 'overview'
const DefaultHash = url({
path: '/docs',
hash: enumOf(['overview', 'comments']).default('overview'),
});
DefaultHash.parse('/docs').hash;
// 'overview'
DefaultHash.build({ hash: 'overview' }, { defaults: 'omit' });
// '/docs'Safe APIs
Safe APIs return discriminated result objects instead of throwing for ordinary validation errors.
const parsed = UserUrl.safeParse('/users/not-a-number');
if (parsed.success) {
parsed.data.params.id;
} else {
parsed.error.code;
}
const normalized = UserUrl.safeNormalize({ params: { id: 'wrong' as never } });
const request = UserUrl.safeParseRequest(new Request('https://example.com/users/42'));Safe result shape:
type SafeResult<Data> =
| { readonly success: true; readonly data: Data }
| { readonly success: false; readonly error: UrlKitError };Request parsing
parseRequest and safeParseRequest support web-standard Request and request-like { url: string } inputs. Use baseUrl for relative request-like URLs.
UserUrl.parseRequest(new Request('https://example.com/users/42?page=2'));
UserUrl.safeParseRequest({ url: '/users/42?page=2' }, { baseUrl: 'https://example.com' });No Express, Hono, Fastify, or framework middleware dependency is required. Framework integrations can pass request URLs or use normalize with already-extracted params/search/hash.
Static descriptors
Static descriptors are for tooling and router-compatible definitions. They must remain statically analyzable, so do not use runtime builders in static route definitions.
Good:
const searchDescriptor = {
page: { type: 'int', default: 1 },
sort: {
type: 'enum',
values: ['newest', 'popular'],
default: 'newest',
},
} as const;Bad:
import { int } from '@cookbook/urlkit';
const searchDescriptor = {
page: int().default(1),
};Compile static descriptors through @cookbook/urlkit/static:
import { compileStaticUrl } from '@cookbook/urlkit/static';
const ProductUrl = compileStaticUrl({
path: '/products/{id:int}',
search: searchDescriptor,
hash: { type: 'enum', values: ['details', 'reviews'], optional: true },
});
ProductUrl.parse('/products/42?sort=popular#details');Router-runtime usage
@cookbook/urlkit/router-runtime contains framework-agnostic helpers for router packages. It does not define routes, route IDs, route trees, loaders, middleware, components, or hooks.
import {
buildSearch,
createRouteUrlContract,
parseHash,
parseSearch,
patchSearch,
} from '@cookbook/urlkit/router-runtime';
const routeDescriptor = {
path: '/articles/{slug:regex([a-z0-9-]+)}',
search: {
ref: { type: 'string', optional: true },
page: { type: 'int', default: 1 },
publishedOn: {
type: 'date',
format: 'dd-MM-yyyy',
optional: true,
},
scheduledAt: {
type: 'date-time',
format: 'dd-MM-yyyy HH:mm:ss',
optional: true,
},
},
hash: { type: 'enum', values: ['comments', 'share'], optional: true },
} as const;
const ArticleUrl = createRouteUrlContract(routeDescriptor);
ArticleUrl.parse('/articles/post-1?ref=email#comments');
// Router-runtime params default to raw strings.
const parsed = parseSearch('?page=2&publishedOn=02-06-2026', {
schema: routeDescriptor.search,
});
const partial = ArticleUrl.parseSearch(
'/articles/post-1?page=2&publishedOn=02-06-2026&scheduledAt=foo',
{
invalidSearch: 'omit',
},
);
const next = buildSearch(
{ page: 3, scheduledAt: new Date('2026-06-02T12:30:05.000Z') },
{ schema: routeDescriptor.search },
);
const patched = patchSearch('?page=2&ref=email', { page: 3 }, { schema: routeDescriptor.search });
const section = parseHash('#comments', routeDescriptor.hash);
const missingSection = parseHash('#overview', routeDescriptor.hash, { invalidHash: 'omit' });Additional router-runtime helpers:
import {
buildHash,
normalizeHash,
omitSearch,
pickSearch,
replaceSearch,
} from '@cookbook/urlkit/router-runtime';Use { params: 'parsed' } with createRouteUrlContract when a router wants URLKit to parse numeric PathKit constraints such as int, decimal, and range to numbers.
Error handling
All URLKit validation and descriptor errors use UrlKitError.
import { UrlKitError } from '@cookbook/urlkit';
try {
UserUrl.parse('/users/not-a-number');
} catch (error) {
if (error instanceof UrlKitError) {
console.log(error.code, error.path);
}
}| Code | Meaning |
| -------------------- | ------------------------------------------------------------------------------------------ |
| invalid-url | URL input could not be parsed as a URL. |
| path-mismatch | URL pathname does not satisfy the path contract. |
| missing-param | Required path param is missing. |
| invalid-param | Path param is invalid. Constraint failures preserve the original PathKit error in cause. |
| missing-search | Required search field is missing. |
| invalid-search | Search value, unknown search behavior, or object search shape is invalid. |
| invalid-hash | Hash value is missing or invalid. |
| invalid-descriptor | Contract/schema/static descriptor is invalid at construction/compile time. |
TypeScript inference
URLKit infers path params, pathnames, search values, and hash values from the contract.
const UserUrl = url({
path: '/users/{id:int}',
search: {
tab: enumOf(['profile', 'settings']).default('profile'),
},
hash: enumOf(['activity', 'comments']).optional(),
});
const state = UserUrl.parse('/users/42?tab=settings#activity');
state.pathname;
// `/users/${number}`
state.params.id;
// number
state.search.tab;
// 'profile' | 'settings'
state.hash;
// 'activity' | 'comments' | undefinedPathless contracts use pathname: string because they validate search/hash independently of the path.
const Query = search({ q: string() });
const state = Query.parse('/anything?q=url');
state.pathname;
// stringFramework boundary
URLKit core is intentionally framework-agnostic:
- no React APIs
- no framework middleware
- no route definitions or route trees
- no loaders/actions
- no Express/Hono/Fastify/Next.js adapters
Router and framework packages can consume URLKit contracts through serialized URLs, Request, request-like { url: string }, or structured normalize input.
Testing and development
pnpm install
npm run typecheck
npm test
npm run buildNo lint script is currently configured in package.json.
