{"id":50622175,"url":"https://github.com/the-cookbook/urlkit","last_synced_at":"2026-06-06T13:02:04.265Z","repository":{"id":362344931,"uuid":"1258177240","full_name":"the-cookbook/urlkit","owner":"the-cookbook","description":"Type-safe URL contracts for parsing, validating, matching, and building URL state across routers, servers, browsers, and edge runtimes.","archived":false,"fork":false,"pushed_at":"2026-06-03T19:15:04.000Z","size":28102,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-03T21:05:31.752Z","etag":null,"topics":["edge-runtime","hash-fragments","path-params","query-string","request-parsing","routing","search-params","static-analysis","typed-url","url","url-builder","url-parser","validation"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/the-cookbook.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-03T10:41:15.000Z","updated_at":"2026-06-03T19:15:11.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/the-cookbook/urlkit","commit_stats":null,"previous_names":["the-cookbook/urlkit"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/the-cookbook/urlkit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/the-cookbook%2Furlkit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/the-cookbook%2Furlkit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/the-cookbook%2Furlkit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/the-cookbook%2Furlkit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/the-cookbook","download_url":"https://codeload.github.com/the-cookbook/urlkit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/the-cookbook%2Furlkit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33983046,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-06T02:00:07.033Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["edge-runtime","hash-fragments","path-params","query-string","request-parsing","routing","search-params","static-analysis","typed-url","url","url-builder","url-parser","validation"],"created_at":"2026-06-06T13:01:59.993Z","updated_at":"2026-06-06T13:02:04.244Z","avatar_url":"https://github.com/the-cookbook.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @cookbook/urlkit\n\n[![npm version](https://img.shields.io/npm/v/@cookbook/urlkit.svg)](https://www.npmjs.com/package/@cookbook/urlkit)\n[![npm downloads](https://img.shields.io/npm/dm/@cookbook/urlkit.svg)](https://www.npmjs.com/package/@cookbook/urlkit)\n[![Bundle size](https://img.shields.io/bundlephobia/minzip/@cookbook/urlkit)](https://bundlephobia.com/package/@cookbook/urlkit)\n[![CI](https://github.com/the-cookbook/urlkit/actions/workflows/ci.yml/badge.svg)](https://github.com/the-cookbook/urlkit/actions/workflows/ci.yml)\n\nFramework-agnostic typed URL contracts for parsing, validating, normalizing, matching, and building URL state.\n\nURLKit owns typed URL state: path params, search params, hash fragments, request parsing, URL normalization, matching, and href building. It sits between `@cookbook/pathkit` and higher-level router packages, but it does not define routes, route IDs, route trees, loaders, middleware, React hooks, components, or framework adapters.\n\n## Status\n\n`@cookbook/urlkit` is currently version `0.0.0`. The implementation is covered by type, unit, integration, documentation-example, and build checks, but the package should be treated as pre-1.0 until its public API is released.\n\n## Documentation\n\n- [Full API reference](./docs/api.md)\n- [Focused examples](./docs/examples.md)\n- [Release readiness notes](./release-readiness.md)\n\n## Real-world framework examples\n\nFull integration examples are available under [`examples/integrations`](./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.\n\n## Table of contents\n\n- [Documentation](#documentation)\n- [Real-world framework examples](#real-world-framework-examples)\n- [Installation](#installation)\n- [Quick start](#quick-start)\n- [Why URLKit?](#why-urlkit)\n- [Package exports](#package-exports)\n- [Core concepts](#core-concepts)\n  - [`parse`, `normalize`, `build`, and `match`](#parse-normalize-build-and-match)\n  - [`UrlState`](#urlstate)\n  - [Path-based vs pathless contracts](#path-based-vs-pathless-contracts)\n- [Path-based URL contracts](#path-based-url-contracts)\n  - [Custom path constraints](#custom-path-constraints)\n- [Pathless URL contracts](#pathless-url-contracts)\n  - [Search-only helper](#search-only-helper)\n  - [Hash-only helper](#hash-only-helper)\n- [Search params](#search-params)\n- [Unknown search params](#unknown-search-params)\n- [Defaults behavior](#defaults-behavior)\n- [Dates](#dates)\n- [Object search](#object-search)\n- [Hash](#hash)\n- [Safe APIs](#safe-apis)\n- [Request parsing](#request-parsing)\n- [Static descriptors](#static-descriptors)\n- [Router-runtime usage](#router-runtime-usage)\n- [Error handling](#error-handling)\n- [TypeScript inference](#typescript-inference)\n- [Framework boundary](#framework-boundary)\n- [Testing and development](#testing-and-development)\n\n## Installation\n\n```sh\npnpm add @cookbook/urlkit\nnpm install @cookbook/urlkit\nyarn add @cookbook/urlkit\n```\n\n## Quick start\n\n```ts\nimport { int, string, url } from '@cookbook/urlkit';\n\nconst UserUrl = url({\n  path: '/users/{id:int}',\n  search: {\n    tab: string().default('profile'),\n    page: int().default(1),\n  },\n});\n\nconst state = UserUrl.parse('/users/42?tab=settings\u0026page=2');\n// state.params.id: number\n// state.search.tab: string\n\nconst href = UserUrl.build({\n  params: { id: 42 },\n  search: { tab: 'settings', page: 2 },\n});\n// '/users/42?tab=settings\u0026page=2'\n```\n\n## Why URLKit?\n\nURLs usually cross boundaries as strings, but application code wants typed state. URLKit gives you one reusable contract for:\n\n- parsing serialized URLs into typed state\n- normalizing structured params/search/hash from framework or server inputs\n- building canonical URLs from typed state\n- validating and matching URLs without routing dependencies\n- sharing the same URL contract across browser, server, edge, router, CLI, and test environments\n\n## Package exports\n\n| Import path                       | Purpose                                                                      |\n| --------------------------------- | ---------------------------------------------------------------------------- |\n| `@cookbook/urlkit`                | Runtime URL contracts, schema builders, public contracts, and `UrlKitError`. |\n| `@cookbook/urlkit/static`         | Static descriptor compilers for router-compatible analyzable descriptors.    |\n| `@cookbook/urlkit/router-runtime` | Framework-agnostic runtime helpers for router packages.                      |\n\n```ts\nimport { url, search, hash, string, int, enumOf } from '@cookbook/urlkit';\nimport { compileStaticUrl } from '@cookbook/urlkit/static';\nimport { createRouteUrlContract } from '@cookbook/urlkit/router-runtime';\n```\n\n## Core concepts\n\n### `parse`, `normalize`, `build`, and `match`\n\n| Method      | Input                                   | Purpose                                                                   |\n| ----------- | --------------------------------------- | ------------------------------------------------------------------------- |\n| `parse`     | Serialized URL input: `string` or `URL` | Parse and validate a URL string/object into typed `UrlState`.             |\n| `normalize` | Structured URL state                    | Validate/coerce params, search, and hash from application/framework data. |\n| `build`     | Typed URL state                         | Serialize state to a canonical URL string.                                |\n| `match`     | Serialized URL input: `string` or `URL` | Return `true`/`false` for ordinary URL validation.                        |\n\n`parse` intentionally does **not** accept structured objects. Use `normalize` for structured state.\n\n### `UrlState`\n\nParsed and normalized state always includes `pathname`, `params`, `search`, and `hash`. Optional hashes are represented as `undefined`; the `hash` property itself is still present.\n\n```ts\ninterface UrlState\u003cPathname, Params, Search, Hash\u003e {\n  readonly pathname: Pathname;\n  readonly params: Params;\n  readonly search: Search;\n  readonly hash: Hash;\n  readonly unknownSearch?: UnknownSearchParams;\n}\n```\n\nPreserved unknown search params live in `state.unknownSearch`, not in `state.search`.\n\n### Path-based vs pathless contracts\n\n| Mode       | How to create it | Path behavior                               | Build behavior                                                      |\n| ---------- | ---------------- | ------------------------------------------- | ------------------------------------------------------------------- |\n| Path-based | Provide `path`   | Validates pathnames and infers path params. | Builds from `params`.                                               |\n| Pathless   | Omit `path`      | Accepts any pathname.                       | Without `pathname`, returns a suffix like `?page=2` or `#comments`. |\n\n## Path-based URL contracts\n\nPath-based contracts use `@cookbook/pathkit` for path pattern matching/building. URLKit adds typed URL state around those paths.\n\n```ts\nimport { url } from '@cookbook/urlkit';\n\nconst ArticleUrl = url({\n  path: '/articles/{slug:regex([a-z0-9-]+)}',\n});\n\nconst state = ArticleUrl.parse('/articles/post-1');\n// state.pathname: `/articles/${string}`\n// state.params.slug: string\n\nArticleUrl.build({ params: { slug: 'post-1' } });\n// '/articles/post-1'\n\nArticleUrl.match('/articles/post-1');\n// true\n\nArticleUrl.match('/users/post-1');\n// false\n```\n\nPath-based build input uses `params`, not `pathname`:\n\n```ts\nArticleUrl.build({ params: { slug: 'post-1' } });\n\n// Invalid for path-based contracts:\n// ArticleUrl.build({ pathname: '/articles/post-1' });\n```\n\nPath params are inferred from the pattern. Built-in `int`, `decimal` and `range` path constraints parse to numbers in standalone `url(...)` contracts.\n\n```ts\nconst UserUrl = url({ path: '/users/{id:int}' });\n\nconst user = UserUrl.parse('/users/42');\n// user.params.id: number\n```\n\n### Custom path constraints\n\nURLKit re-exports PathKit's `createConstraint` and provides global registration helpers for reusable path constraints. Custom constraints infer `string` params by default; built-in `int`, `decimal` and `range` still infer `number`.\n\n```ts\nimport { createConstraint, registerPathConstraint, url } from '@cookbook/urlkit';\n\nconst slug = createConstraint({\n  parse(paramName, value) {\n    if (!/^[a-z0-9-]+$/.test(String(value))) {\n      throw new Error(`Path parameter \"${paramName}\" must be a slug.`);\n    }\n  },\n  verify(paramName, params) {\n    if (params.trim()) {\n      throw new Error(`Constraint \"slug\" declared for \"${paramName}\" does not accept arguments.`);\n    }\n  },\n  toRegExp() {\n    return '[a-z0-9-]+';\n  },\n});\n\nregisterPathConstraint('slug', slug);\n\nconst ArticleUrl = url({\n  path: '/articles/{slug:slug}',\n});\n\nArticleUrl.parse('/articles/hello-world').params.slug;\n// string\n```\n\nUse per-contract registration when a constraint should be local to a contract or test:\n\n```ts\nconst ArticleUrl = url({ path: '/articles/{slug:slug}' }, { pathConstraints: { slug } });\n```\n\n## Pathless URL contracts\n\nPathless contracts validate search/hash independently of pathname. `pattern` is `undefined`, `params` is `{}`, and `parse` preserves the input pathname.\n\n```ts\nimport { int, url } from '@cookbook/urlkit';\n\nconst FiltersUrl = url({\n  search: {\n    page: int().default(1),\n  },\n});\n\nFiltersUrl.build({\n  search: { page: 2 },\n});\n// '?page=2'\n\nFiltersUrl.build({\n  pathname: '/products',\n  search: { page: 2 },\n});\n// '/products?page=2'\n\nFiltersUrl.parse('/anything?page=3').pathname;\n// '/anything'\n```\n\n### Search-only helper\n\n```ts\nimport { int, search, string } from '@cookbook/urlkit';\n\nconst ProductSearch = search({\n  category: string().optional(),\n  page: int().default(1),\n});\n\nProductSearch.build({ search: { page: 2 } });\n// '?page=2'\n```\n\n### Hash-only helper\n\n```ts\nimport { enumOf, hash } from '@cookbook/urlkit';\n\nconst DocsHash = hash(enumOf(['intro', 'api']).optional());\n\nDocsHash.parse('/docs#api').hash;\n// 'api'\n\nDocsHash.build({ hash: 'api' });\n// '#api'\n```\n\n## Search params\n\nRuntime search schemas use builders from the main entry.\n\n```ts\nimport { array, boolean, enumOf, int, number, string, url } from '@cookbook/urlkit';\n\nconst SearchUrl = url({\n  path: '/search',\n  search: {\n    q: string(),\n    page: int().default(1),\n    score: number().optional(),\n    active: boolean().optional(),\n    tags: array(string()).optional(),\n    sort: enumOf(['newest', 'popular']).default('newest'),\n  },\n});\n\nSearchUrl.parse('/search?q=url\u0026page=2\u0026active=true\u0026tags=ts\u0026tags=router');\n\nSearchUrl.build({\n  search: {\n    q: 'url',\n    page: 2,\n    active: true,\n    tags: ['ts', 'router'],\n    sort: 'newest',\n  },\n});\n// '/search?q=url\u0026page=2\u0026active=true\u0026tags=ts\u0026tags=router\u0026sort=newest'\n```\n\nArrays 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.\n\n```ts\nconst TagUrl = url(\n  {\n    path: '/search',\n    search: {\n      tags: array(string()).optional(),\n    },\n  },\n  { arrayFormat: 'comma' },\n);\n\nTagUrl.parse('/search?tags=ts%2Crouter').search.tags;\n// ['ts', 'router']\n\nTagUrl.build({ search: { tags: ['ts', 'router'] } });\n// '/search?tags=ts%2Crouter'\n\nTagUrl.build({ search: { tags: ['ts', 'router'] } }, { arrayFormat: 'repeat' });\n// '/search?tags=ts\u0026tags=router'\n```\n\n## Unknown search params\n\nUnknown search params default to `strip`.\n\n| Behavior   | Result                                          |\n| ---------- | ----------------------------------------------- |\n| `strip`    | Remove unknown params from typed state.         |\n| `preserve` | Put unknown params in `state.unknownSearch`.    |\n| `error`    | Throw `UrlKitError` with code `invalid-search`. |\n\n```ts\nconst QueryUrl = url({\n  search: {\n    q: string(),\n  },\n});\n\nQueryUrl.parse('/search?q=router\u0026debug=true');\n// search: { q: 'router' }\n\nQueryUrl.parse('/search?q=router\u0026debug=true', { unknownSearch: 'preserve' });\n// search: { q: 'router' }\n// unknownSearch: { debug: 'true' }\n\nQueryUrl.safeParse('/search?q=router\u0026debug=true', { unknownSearch: 'error' });\n// { success: false, error: UrlKitError }\n```\n\n## Defaults behavior\n\n`parse` and `normalize` always apply defaults. `build` serializes the values it receives and includes defaults by default.\n\n```ts\nconst Paging = search({\n  page: int().default(1),\n});\n\nPaging.parse('/products').search;\n// { page: 1 }\n\nPaging.build({ search: { page: 1 } });\n// '?page=1'\n\nPaging.build({ search: { page: 1 } }, { defaults: 'omit' });\n// ''\n```\n\nDefault omission compares normalized values, so defaults are compared after the same validation/coercion rules used by the contract.\n\n## Dates\n\n```ts\nimport { date, dateTime, search } from '@cookbook/urlkit';\n\nconst Reports = search({\n  day: date(),\n  at: dateTime().optional(),\n  createdAt: date({ format: 'unix-seconds' }).optional(),\n  updatedAt: date({ format: 'unix-ms' }).optional(),\n});\n```\n\n| Builder                                  | Serialized format                      |\n| ---------------------------------------- | -------------------------------------- |\n| `date()`                                 | Date-only `YYYY-MM-DD`.                |\n| `dateTime()`                             | Strict UTC `YYYY-MM-DDTHH:mm:ss.sssZ`. |\n| `date({ format: 'unix-seconds' })`       | Finite integer seconds.                |\n| `date({ format: 'unix-ms' })`            | Finite integer milliseconds.           |\n| `date({ format: { parse, serialize } })` | Custom runtime date codec.             |\n\nCustom runtime date codecs are available only in runtime-builder schemas. Static date defaults use serialized values, not `Date` instances.\n\n```ts\nconst CustomDate = search({\n  from: date({\n    format: {\n      parse(value) {\n        const [day, month, year] = value.split('-');\n        return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));\n      },\n      serialize(value) {\n        const day = String(value.getUTCDate()).padStart(2, '0');\n        const month = String(value.getUTCMonth() + 1).padStart(2, '0');\n        return `${day}-${month}-${value.getUTCFullYear()}`;\n      },\n    },\n  }),\n});\n\nCustomDate.build({ search: { from: new Date('2026-06-02T00:00:00.000Z') } });\n// '?from=02-06-2026'\n```\n\n## Object search\n\n`object(...)` hydrates declared object fields from dotted search keys. Raw search parsing without a schema remains flat.\n\n```ts\nimport { boolean, object, string, search } from '@cookbook/urlkit';\n\nconst Filters = search({\n  filter: object({\n    role: string().optional(),\n    active: boolean().optional(),\n    'user.name': string().optional(),\n  }),\n});\n\nFilters.build({\n  search: {\n    filter: {\n      role: 'admin',\n      active: true,\n      'user.name': 'Ada',\n    },\n  },\n});\n// '?filter.role=admin\u0026filter.active=true\u0026filter.user%7E1name=Ada'\n```\n\nObject search key rules:\n\n| Rule             | Behavior                                                                      |\n| ---------------- | ----------------------------------------------------------------------------- |\n| Declared objects | Only fields declared with `object(...)` hydrate nested object values.         |\n| Dot notation     | Object fields serialize as `field.child=value`.                               |\n| `~` escaping     | `~` becomes `~0`.                                                             |\n| `.` escaping     | `.` becomes `~1`.                                                             |\n| URL encoding     | Happens after object-key segment escaping.                                    |\n| Collisions       | Ambiguous object/scalar collisions throw `UrlKitError` with `invalid-search`. |\n\n## Hash\n\nHashes support optional, required, enum, and defaulted values. Parsed and normalized `UrlState` always includes a `hash` property.\n\n```ts\nimport { enumOf, hash, string, url } from '@cookbook/urlkit';\n\nconst OptionalHash = hash(enumOf(['intro', 'api']).optional());\nOptionalHash.parse('/docs#api').hash;\n// 'api'\n\nconst RequiredHash = hash(string().required());\nRequiredHash.parse('/docs#overview').hash;\n// 'overview'\n\nconst DefaultHash = url({\n  path: '/docs',\n  hash: enumOf(['overview', 'comments']).default('overview'),\n});\n\nDefaultHash.parse('/docs').hash;\n// 'overview'\n\nDefaultHash.build({ hash: 'overview' }, { defaults: 'omit' });\n// '/docs'\n```\n\n## Safe APIs\n\nSafe APIs return discriminated result objects instead of throwing for ordinary validation errors.\n\n```ts\nconst parsed = UserUrl.safeParse('/users/not-a-number');\n\nif (parsed.success) {\n  parsed.data.params.id;\n} else {\n  parsed.error.code;\n}\n\nconst normalized = UserUrl.safeNormalize({ params: { id: 'wrong' as never } });\nconst request = UserUrl.safeParseRequest(new Request('https://example.com/users/42'));\n```\n\nSafe result shape:\n\n```ts\ntype SafeResult\u003cData\u003e =\n  | { readonly success: true; readonly data: Data }\n  | { readonly success: false; readonly error: UrlKitError };\n```\n\n## Request parsing\n\n`parseRequest` and `safeParseRequest` support web-standard `Request` and request-like `{ url: string }` inputs. Use `baseUrl` for relative request-like URLs.\n\n```ts\nUserUrl.parseRequest(new Request('https://example.com/users/42?page=2'));\n\nUserUrl.safeParseRequest({ url: '/users/42?page=2' }, { baseUrl: 'https://example.com' });\n```\n\nNo Express, Hono, Fastify, or framework middleware dependency is required. Framework integrations can pass request URLs or use `normalize` with already-extracted params/search/hash.\n\n## Static descriptors\n\nStatic descriptors are for tooling and router-compatible definitions. They must remain statically analyzable, so do not use runtime builders in static route definitions.\n\nGood:\n\n```ts\nconst searchDescriptor = {\n  page: { value: 'int', default: 1 },\n  sort: {\n    value: { type: 'enum', values: ['newest', 'popular'] },\n    default: 'newest',\n  },\n} as const;\n```\n\nBad:\n\n```ts\nimport { int } from '@cookbook/urlkit';\n\nconst searchDescriptor = {\n  page: int().default(1),\n};\n```\n\nCompile static descriptors through `@cookbook/urlkit/static`:\n\n```ts\nimport { compileStaticUrl } from '@cookbook/urlkit/static';\n\nconst ProductUrl = compileStaticUrl({\n  path: '/products/{id:int}',\n  search: searchDescriptor,\n  hash: ['details', 'reviews'],\n});\n\nProductUrl.parse('/products/42?sort=popular#details');\n```\n\n## Router-runtime usage\n\n`@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.\n\n```ts\nimport {\n  buildSearch,\n  createRouteUrlContract,\n  parseHash,\n  parseSearch,\n  patchSearch,\n} from '@cookbook/urlkit/router-runtime';\n\nconst routeDescriptor = {\n  path: '/articles/{slug:regex([a-z0-9-]+)}',\n  search: {\n    ref: { type: 'one', optional: true },\n    page: { value: 'int', default: 1 },\n  },\n  hash: ['comments', 'share'],\n} as const;\n\nconst ArticleUrl = createRouteUrlContract(routeDescriptor);\n\nArticleUrl.parse('/articles/post-1?ref=email#comments');\n// Router-runtime params default to raw strings.\n\nconst parsed = parseSearch('?page=2', { schema: routeDescriptor.search });\nconst next = buildSearch({ page: 3 }, { schema: routeDescriptor.search });\nconst patched = patchSearch('?page=2\u0026ref=email', { page: 3 }, { schema: routeDescriptor.search });\nconst section = parseHash('#comments', routeDescriptor.hash);\n```\n\nAdditional router-runtime helpers:\n\n```ts\nimport {\n  buildHash,\n  normalizeHash,\n  omitSearch,\n  pickSearch,\n  replaceSearch,\n} from '@cookbook/urlkit/router-runtime';\n```\n\nUse `{ params: 'parsed' }` with `createRouteUrlContract` when a router wants URLKit to parse `int` and `number` path params to numbers.\n\n## Error handling\n\nAll URLKit validation and descriptor errors use `UrlKitError`.\n\n```ts\nimport { UrlKitError } from '@cookbook/urlkit';\n\ntry {\n  UserUrl.parse('/users/not-a-number');\n} catch (error) {\n  if (error instanceof UrlKitError) {\n    console.log(error.code, error.path);\n  }\n}\n```\n\n| Code                 | Meaning                                                                    |\n| -------------------- | -------------------------------------------------------------------------- |\n| `invalid-url`        | URL input could not be parsed as a URL.                                    |\n| `path-mismatch`      | URL pathname does not satisfy the path contract.                           |\n| `missing-param`      | Required path param is missing.                                            |\n| `invalid-param`      | Path param is invalid.                                                     |\n| `missing-search`     | Required search field is missing.                                          |\n| `invalid-search`     | Search value, unknown search behavior, or object search shape is invalid.  |\n| `invalid-hash`       | Hash value is missing or invalid.                                          |\n| `invalid-descriptor` | Contract/schema/static descriptor is invalid at construction/compile time. |\n\n## TypeScript inference\n\nURLKit infers path params, pathnames, search values, and hash values from the contract.\n\n```ts\nconst UserUrl = url({\n  path: '/users/{id:int}',\n  search: {\n    tab: enumOf(['profile', 'settings']).default('profile'),\n  },\n  hash: enumOf(['activity', 'comments']).optional(),\n});\n\nconst state = UserUrl.parse('/users/42?tab=settings#activity');\n\nstate.pathname;\n// `/users/${number}`\n\nstate.params.id;\n// number\n\nstate.search.tab;\n// 'profile' | 'settings'\n\nstate.hash;\n// 'activity' | 'comments' | undefined\n```\n\nPathless contracts use `pathname: string` because they validate search/hash independently of the path.\n\n```ts\nconst Query = search({ q: string() });\nconst state = Query.parse('/anything?q=url');\n\nstate.pathname;\n// string\n```\n\n## Framework boundary\n\nURLKit core is intentionally framework-agnostic:\n\n- no React APIs\n- no framework middleware\n- no route definitions or route trees\n- no loaders/actions\n- no Express/Hono/Fastify/Next.js adapters\n\nRouter and framework packages can consume URLKit contracts through serialized URLs, `Request`, request-like `{ url: string }`, or structured `normalize` input.\n\n## Testing and development\n\n```sh\npnpm install\nnpm run typecheck\nnpm test\nnpm run build\n```\n\nNo lint script is currently configured in `package.json`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthe-cookbook%2Furlkit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthe-cookbook%2Furlkit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthe-cookbook%2Furlkit/lists"}