├── .github └── workflows │ └── build_publish.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── .nojekyll ├── README.md ├── _sidebar.md ├── advanced-features │ ├── composing-routes.md │ ├── global-query-parameters.md │ ├── replace-dynamic-segments.md │ └── with-base-url.md ├── basic-features │ ├── absolute-routes.md │ ├── nested-routes.md │ ├── parameter-parsing.md │ ├── parameter-types.md │ ├── parameters.md │ ├── relative-routes.md │ └── route-templates.md ├── customization │ └── custom-parameter-types.md ├── index.html └── tutorials │ ├── angular-router.md │ ├── react-router.md │ ├── refine.md │ └── vue-router.md ├── examples └── angular-router │ ├── .gitignore │ ├── README.md │ ├── angular.json │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── app │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── app.routes.ts │ │ ├── dashboard.component.ts │ │ ├── locations.component.ts │ │ └── orgs.component.ts │ ├── index.html │ └── main.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── yarn.lock ├── package.json ├── src ├── adapters │ └── angular-router.ts ├── index.ts ├── params.ts ├── routes.ts └── types.ts ├── test ├── test.ts └── types.ts ├── tsconfig.json └── yarn.lock /.github/workflows/build_publish.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test & Maybe Publish 2 | on: 3 | push: 4 | branches: [ master, dev ] 5 | pull_request: 6 | branches: [ master, dev ] 7 | release: 8 | types: [ published ] 9 | jobs: 10 | build_and_maybe_publish: 11 | timeout-minutes: 60 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Cache Node.js modules 15 | uses: actions/cache@v4 16 | with: 17 | path: ~/.npm 18 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | registry-url: "https://registry.npmjs.org" 24 | - name: Build & Test 25 | run: | 26 | yarn install --frozen-lockfile 27 | yarn build 28 | - name: prerelease 29 | if: github.event_name == 'release' && github.event.release.prerelease 30 | run: npm publish --tag dev 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | - name: publish 34 | if: github.event_name == 'release' && !github.event.release.prerelease 35 | run: npm publish 36 | env: 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | *.log 4 | .parcel-cache 5 | .DS_Store 6 | dist 7 | *.tgz -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "singleAttributePerLine": false, 8 | "bracketSameLine": false, 9 | "jsxBracketSameLine": false, 10 | "jsxSingleQuote": false, 11 | "printWidth": 80, 12 | "proseWrap": "preserve", 13 | "quoteProps": "as-needed", 14 | "requirePragma": false, 15 | "semi": true, 16 | "singleQuote": false, 17 | "tabWidth": 2, 18 | "trailingComma": "es5", 19 | "useTabs": false, 20 | "embeddedLanguageFormatting": "auto", 21 | "vueIndentScriptAndStyle": false, 22 | "parser": "typescript" 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Denis Kruschinski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![minzipped size](https://badgen.net/bundlephobia/minzip/typesafe-routes) 2 | ![minified size](https://badgen.net/bundlephobia/min/typesafe-routes) 3 | ![tree shaking](https://badgen.net/bundlephobia/tree-shaking/typesafe-routes) 4 | [![discord link](https://img.shields.io/badge/Chat%20on-Discord-%235865f2)](https://discord.gg/BCGmvSSJBk) 5 | 6 | # Typesafe Routes 7 | 8 | `typesafe-routes` speeds up app development and cuts down on testing efforts by letting TypeScript catch route and parameter type inconsistencies. Your code will be more robust, making broken links a thing of the past. 9 | 10 | `typesafe-routes` features include: 11 | 12 | - Autocompletion for paths and parameters 13 | - Path & template rendering 14 | - Nested, absolute, and relative path rendering 15 | - Parameter parsing and serialization 16 | - Type-safe, customizable, and extendable 17 | - Compatible with JavaScript (apart from type safety) 18 | - Small bundle size starting at less than 1kb 19 | 20 | ## Example 21 | 22 | [typescript playground](https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAbzgYygUwIYzQJQgV2wGcAaOYAOxjPQoBM0oAFLACzLAyiLRZnbjZwAGyxo4AXzgAzKBBBwARDACeYNEQzS0AWjmENigNwBYAFDmA9Jbj7sg9OIbTKwGMAgVzyT0Xh2NOABeFHQxPAMiAAoEczg4fB5uAC5EOPi4Tn5UgG1FRMYiRTJKGCj84DpFAEoAXTIM6zhLAu5LZPxK9PjkVmBhOlpU2LMMjLQ6N2HMtlzFCbdFWskGxpsWpKJ2zrpLBZhujIZhNGxprNY549O0JZXm9datjsrLa+xDiXSvswlq0wsZiaGAARkQIMIDDN+ERzLQGMw2FEAkQAHRPMhIHapACMACYAMySf4POAAPwAfEoNoVLPiCYpzFYbBQNNg6NDWLCzPDGHxWMiCMR0ZtUftMQlKrjCcSjKTKdSnnTCXtJjBGTy0PQ+UiUSLCqj3mgJdi4PTZfKqYoaW16W80CdsBrmbYHVhgAA3cQXIhwcoAfUU5HowGQYl9-HEfi47goAHM4Dw4yAtTBqnCtQj+YLIqj-fruCapWaZX85U0FflNsqGRntYj+DnhU882K1Ziy5alPtnUCbJxuN62DMoBgQNyBzxs3qW+LEJK6KlFAAWPFBzsVqlY4uryTmSe8XVCjQFtFz61KgCcl9VixJm-npuve8BTSEYFExDg5nfn7QTZPWc1XvGxKxtZ4dlvdUfzQEQxAAtF8yAtwQPJK0lReXYexguDsAQ09W32VDKx7IA) 23 | 24 | ``` ts 25 | import { createRoutes, int, renderPath, parsePath, template } from "typesafe-routes"; 26 | 27 | // route tree definition 28 | const routes = createRoutes({ 29 | users: { 30 | path: ["users", int("uid")], // /users/:uid 31 | children: { 32 | edit: { path: ["edit"] }, // /users/:uid/edit 33 | delete: { path: ["delete"] }, // /users/:uid/delete 34 | } 35 | } 36 | }); 37 | 38 | // absolute paths 39 | renderPath(routes.users, { uid: 123 }); // ~> "/users/123" 40 | 41 | // nested paths 42 | renderPath(routes.users.edit, { uid: 123 }); // ~> "/users/123/edit" 43 | renderPath(routes.users.delete, { uid: 123 }); // ~> "/users/123/delete" 44 | 45 | // relative paths ("_" indicates the starting segment) 46 | renderPath(routes._.users, { uid: 123 }); // ~> "users/123" 47 | renderPath(routes.users._.edit, {}); // ~> "edit" 48 | 49 | // parse path params 50 | parsePath(routes.users.edit, { uid: "42" }); // ~> { uid: 42 } 51 | parsePath(routes.users.edit, "/users/99/edit"); // ~> { uid: 99 } 52 | 53 | // templates 54 | template(routes.users.edit); // ~> "/users/:uid/edit" 55 | template(routes._.users.edit); // ~> "users/:uid/edit" 56 | template(routes.users._.edit); // ~> "edit" 57 | ``` 58 | 59 | ## Quick Reference 60 | 61 | The complete [documentation can be found here](https://kruschid.github.io/typesafe-routes). 62 | 63 | - Functions 64 | - `renderPath`: renders a path with parameters 65 | - `renderQuery`: renders a search query 66 | - `render`: renders a path with parameters including query string 67 | - `template`: renders a route template 68 | - `parsePath`: parses dynamic segments in a path 69 | - `parseQuery`: parses parameters in a search query 70 | - `parse`: parses path and search query for parameters 71 | - `replace`: partially replaces dynamic segments and query params in a string-based path (i.e. `location.path`) 72 | 73 | ## Installation 74 | 75 | ``` sh 76 | npm i typesafe-routes # or any npm alternative 77 | ``` 78 | 79 | ## How to Contribute 80 | 81 | - leave a star ⭐ 82 | - report a bug 🐞 83 | - open a pull request 🏗️ 84 | - please discuss your idea on github or discord **before you start working on your PR** 85 | - help others ❤️ 86 | - [buy me a coffee ☕](https://www.buymeacoffee.com/kruschid) 87 | 88 | Buy Me A Coffee 89 | 90 | ## Roadmap 91 | 92 | - v10-v12 migration guide 93 | - v12beta-v12.1 migration guide 94 | - check for duplicate param names in the route tree 95 | - customizable parsing of search params (for example with qs) 96 | - demos & utils 97 | - react-router 98 | - wouter 99 | - vue router 100 | - angular router 101 | - refinejs 102 | 103 | ## Docs 104 | 105 | - [x] quickstart 106 | - basic-features 107 | - [x] absolute-routes 108 | - [x] parameters 109 | - [x] nested-routes 110 | - [x] relative-routes 111 | - [x] route-templates 112 | - [x] parameter-parsing 113 | - [x] parameter-binding 114 | - [x] parameter-types 115 | - advanced-features 116 | - [x] replace-dynamic-segments 117 | - [x] global-query-parameters 118 | - customization 119 | - [x] custom-parameter-types 120 | - tutorials 121 | - [x] angular router 122 | - [ ] react router 123 | - [ ] wouter 124 | - [ ] vue router 125 | - [ ] refine 126 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kruschid/typesafe-routes/59c1cb35c3685b12136e37ebec296c03e6fdf0e8/docs/.nojekyll -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Typesafe Routes 2 | 3 | ## Quickstart 4 | 5 | ### 1. Installation 6 | 7 | ``` bash 8 | npm install typesafe-routes # or any npm alternative 9 | ``` 10 | 11 | ### 2. Route Tree Definition 12 | 13 | ``` ts 14 | import { createRoutes, int } from "typesafe-routes"; 15 | 16 | const routes = createRoutes({ 17 | groups: { 18 | path: ["groups", int("gid")], 19 | children: { 20 | users: { 21 | path: ["users", int.optional("uid")], 22 | } 23 | } 24 | } 25 | }); 26 | ``` 27 | 28 | ### 3. Path Rendering 29 | 30 | ``` ts 31 | // only required params 32 | renderPath(routes.groups.users, { 33 | gid: 123, 34 | }); // ~> "/groups/123/users" 35 | 36 | // with optional param 37 | renderPath(routes.groups.users, { 38 | gid: 123, 39 | uid: 456, 40 | }); // ~> "/groups/123/users/456" 41 | ``` 42 | 43 | ### 4. Discover More Features 44 | 45 | To access the other examples, use the navigation on the left (if on mobile, click the burger icon in the bottom left corner). 46 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | * [Quickstart](README.md) 2 | * Basic Features 3 | * [Absolute Routes](basic-features/absolute-routes.md) 4 | * [Parameters](basic-features/parameters.md) 5 | * [Nested Routes](basic-features/nested-routes.md) 6 | * [Relative Routes](basic-features/relative-routes.md) 7 | * [Route Templates](basic-features/route-templates.md) 8 | * [Parameter Parsing](basic-features/parameter-parsing.md) 9 | * [Parameter Types](basic-features/parameter-types.md) 10 | * Advanced Features 11 | * [Replace Dynamic Segments](advanced-features/replace-dynamic-segments.md) 12 | * [Global Query Parameters](advanced-features/global-query-parameters.md) 13 | * [Composing Routes](advanced-features/composing-routes.md) 14 | * [Links With Base URL](advanced-features/with-base-url.md) 15 | * Customization 16 | * [Custom Parameter Types](customization/custom-parameter-types.md) 17 | * Tutorials 18 | * [Angular Router](tutorials/angular-router.md) 19 | * [React Router](tutorials/react-router.md) 20 | * [Refine](tutorials/refine.md) 21 | * [Vue Router](tutorials/vue-router.md) 22 | -------------------------------------------------------------------------------- /docs/advanced-features/composing-routes.md: -------------------------------------------------------------------------------- 1 | # Composing Routes 2 | 3 | Routes that belong to a feature or component can be shared across projects. The entire route tree, passed to `createRoute` as the first argument, can be accessed via the `~routes` property, including the types. This eliminates the need to define and export the route tree in a separate step. 4 | 5 | ``` ts 6 | // user.routes.ts 7 | const usersRoutes = createRoutes({ 8 | detail: { 9 | path: ["detail", int("uid")], 10 | }, 11 | }); 12 | 13 | // global.routes.ts 14 | const globalRoutes = createRoutes({ 15 | home: { 16 | path: ["home"], 17 | }, 18 | }); 19 | 20 | // app.routes.ts 21 | const routes = createRoutes({ 22 | ...globalRoutes.["~routes"], 23 | user: { 24 | path: ["user"], 25 | children: usersRoutes.["~routes"], 26 | }, 27 | }); 28 | 29 | renderPath(routes.home, {}); // => "/home" 30 | renderPath(routes.user.detail, { uid: 123 }); // => "/user/detail/123" 31 | ``` 32 | 33 | This example specifies all nodes of `usersRoute.["~routes"]` as children of `routes.user`. The `globalRoutes["~routes"]` object, on the other hand, is merged into the routes at the root level. 34 | -------------------------------------------------------------------------------- /docs/advanced-features/global-query-parameters.md: -------------------------------------------------------------------------------- 1 | # Global Query Parameters 2 | 3 | Global query parameters can be defined by setting the `query` property in the root route and deriving all the remaining routes as children from it. 4 | 5 | 6 | ```ts 7 | import { createRoutes, str, render } from "typesafe-routes"; 8 | 9 | const routes = createRoutes({ 10 | state: { 11 | query: [str("state")], 12 | children: { 13 | users: { 14 | path: ["users"] 15 | } 16 | } 17 | }, 18 | }); 19 | ``` 20 | 21 | 22 | ## **Absolute Routes** 23 | 24 | In absolute routes query parameters that were defined at root level are included in any path that contains the root node. 25 | 26 | ```ts 27 | render(routes.users, { 28 | path: {}, 29 | query: { 30 | state: "e2hlbGxvOiJ3b3JsZCJ9" 31 | }); // ~> "/users?state=e2hlbGxvOiJ3b3JsZCJ9" 32 | ``` 33 | 34 | ## **Relative Routes** 35 | 36 | In relative routes, the `_` link can be used to render a subpath. However, this doesn't have any effect on the query parameters. Meaning a query parameter that was defined at the root level is never omitted when rendering a relative path. 37 | 38 | ```ts 39 | render(routes._.users, { 40 | path: {}, 41 | query: { 42 | state: "e2hlbGxvOiJ3b3JsZCJ9" 43 | }); // ~> "users?state=e2hlbGxvOiJ3b3JsZCJ9" 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/advanced-features/replace-dynamic-segments.md: -------------------------------------------------------------------------------- 1 | # Replace dynamic segments 2 | 3 | The `replace` function re-renders an existing string location with modified parameter values. For example, given the location `/groups/42/users/1337`, the `replace` function could be used to change the first dynamic segment from `42` to `24` to create the new path `/groups/24/users/1337`. 4 | 5 | ``` ts 6 | import { createRoutes, int } from "typesafe-routes"; 7 | 8 | const routes = createRoutes({ 9 | groups: { 10 | path: ["groups", int("gid")], 11 | query: [int.optional("page")], 12 | children: { 13 | users: { 14 | path: ["users", int.optional("uid")], 15 | } 16 | } 17 | } 18 | }); 19 | ``` 20 | 21 | 22 | ## **Basic Usage** 23 | 24 | In the provided path, one dynamic segment is altered while the other trailing segments remain unchanged. One of the provided search parameters has been modified while the other one stays the same. The `replace` function doesn't omit unknown query parameters, which is why the `offset` parameter is part of the returned string despite not being specified in the route tree above. 25 | 26 | ``` ts 27 | const locationPath = "/groups/32/extra/segments?page=2&offset=2"; 28 | 29 | replace(routes.groups, locationPath, { 30 | path: { gid: 1337 }, 31 | query: { page: 7 }, 32 | }) // ~> "/groups/1337/extra/segments?page=7&offset=2" 33 | ``` 34 | 35 | ## **Nested Routes** 36 | 37 | The `replace` function allows the simultaneous replacement of multiple nested routes. Here we only change dynamic segments that belong to `group` and `users`. The trailing segments remain unchanged even though they are not present in the route tree. 38 | 39 | ``` ts 40 | const locationPath = "/groups/32/users/33/extra/segments"; 41 | 42 | replace(routes.group.users, locationPath, { 43 | path: { gid: 1337, uid: 666 }, 44 | }) // ~> "/groups/1337/users/666/extra/segments" 45 | ``` 46 | 47 | ## **Relative Routes** 48 | 49 | The `replace` function is fully compatible with the underscore `_` link for modifying a relative subpath. 50 | 51 | ``` ts 52 | const locationPath = "users/32/extra/segments"; // the source is a relative path 53 | 54 | replace(routes.groups._.users, locationPath, { 55 | path: { uid: 1337 }, 56 | }) // ~> "users/1337/extra/segments" 57 | ``` 58 | 59 | ## **Parameter Deletion** 60 | 61 | To remove an optional parameter from the resulting path, simply assign `undefined` to it. 62 | 63 | ``` ts 64 | const locationPath = "/groups/42/users/32/extra/segments"; 65 | 66 | replace(routes.groups.users, locationPath, { 67 | path: { uid: undefined }, 68 | }) // => "/groups/42/users/extra/segments" 69 | ``` 70 | 71 | > [!WARNING] 72 | > Removing a required parameter value will throw an exception. 73 | 74 | -------------------------------------------------------------------------------- /docs/advanced-features/with-base-url.md: -------------------------------------------------------------------------------- 1 | # Base Url 2 | 3 | 4 | This page shows a sample code fragment that handles scenarios where a base URL needs to be rendered in links. 5 | 6 | The code below creates two separate route trees: one for internal application routes without a base URL prefix, and a second route tree that extends the first one by adding a root node with a path segment that holds the application's base URL. 7 | 8 | ``` ts 9 | import { createRoutes, renderPath } from "typesafe-routes" 10 | 11 | // The base URL is hardcoded in this example but could also be set dynamically 12 | const APP_URL = "https://localhost"; 13 | 14 | const internalRoutes = createRoutes({ 15 | register: { 16 | path: ["register"], 17 | }, 18 | ... 19 | }); 20 | 21 | renderPath(internalRoutes.register, {}) // ~> /register 22 | 23 | const externalRoutes = createRoutes({ 24 | baseUrl: { 25 | path: [APP_URL], 26 | children: internalRoutes["~routes"], 27 | }, 28 | }); 29 | 30 | renderPath(externalRoutes.baseUrl.register, {}) // ~> https://localhost/register 31 | ``` 32 | 33 | Both route trees could be exported and utilized as an npm package within a monorepo. 34 | -------------------------------------------------------------------------------- /docs/basic-features/absolute-routes.md: -------------------------------------------------------------------------------- 1 | # Absolute routes 2 | 3 | The method `createRoutes` takes an object, where property names represent route names. Each route may specify a `path` using a string array. The string array can contain a variable number of segments. 4 | 5 | ``` ts 6 | import { createRoutes, renderPath } from "typesafe-routes"; 7 | 8 | const routes = createRoutes({ 9 | // route segment: "about" 10 | about: { 11 | // single segment path 12 | path: ["about-us"] 13 | }, 14 | // route segment: "blogCategories" 15 | blogCategories: { 16 | // multiple segments 17 | path: ["blog", "categories"] 18 | } 19 | }); 20 | ``` 21 | 22 | The paths defined using the `createRoutes` function can be rendered using the `renderPath` function. 23 | 24 | 25 | ## **Root Path** 26 | 27 | The root path `"/"` is returned when the `renderPath` function is called with `routes`. 28 | 29 | ``` ts 30 | renderPath(routes, {}) // ~> "/" 31 | ``` 32 | 33 | An empty object is `renderPath`'s second argument. It shows that there are no parameters that require rendering. Refer to other sections for detailed examples illustrating the effective utilization of parameters. 34 | 35 | ## **Route Segments** 36 | 37 | When the `renderPath` functions is called with a route segment that was specified with the `createRoutes` function call above, the matching path is rendered. 38 | 39 | ``` ts 40 | renderPath(routes.about, {}); // ~> "/about-us" 41 | renderPath(routes.blogCategories, {}); // ~> "/blog/categories" 42 | ``` 43 | 44 | An empty object is `renderPath`'s second argument. It shows that there are no parameters that require rendering. Refer to other sections for detailed examples illustrating the effective utilization of parameters. 45 | 46 | -------------------------------------------------------------------------------- /docs/basic-features/nested-routes.md: -------------------------------------------------------------------------------- 1 | # Nested routes 2 | 3 | Nested route segments are defined through the `children` property, containing an object with nested route segments. 4 | 5 | ``` ts 6 | import { createRoutes, renderPath } from "typesafe-routes"; 7 | 8 | const routes = createRoutes({ 9 | blog: { 10 | path: ["blog"], 11 | children: { 12 | categories: { 13 | path: ["categories"], 14 | children: { 15 | movies: { 16 | path: ["movies"] 17 | }, 18 | } 19 | } 20 | } 21 | } 22 | }); 23 | ``` 24 | 25 | To render nested segments, chain the corresponding route-nodes based on their hierarchy and use `renderPath`, `renderQuery` or `render` on them. 26 | 27 | ``` ts 28 | renderPath(routes.blog, {}); // ~> "/blog" 29 | 30 | renderPath(routes.blog.categories, {}); // ~> "/blog/categories" 31 | 32 | renderPath(routes.blog.categories.movies, {}); // ~> "/blog/categories/movies" 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/basic-features/parameter-parsing.md: -------------------------------------------------------------------------------- 1 | # Parameter Parsing 2 | 3 | This section explains how to parse path and query parameters. Parsing converts string values into their corresponding types that were specified in the route tree. These values may originate from various sources. For instance, they can be in the form of an object provided by router libraries such as [React Router](https://reactrouter.com). Alternatively, they might come from the global [Location](https://developer.mozilla.org/en-US/docs/Web/API/Location) object as a string. This flexibility allows for effective handling of parameter values regardless of their source. 4 | 5 | ## Path Parameters 6 | 7 | Path parameter values are dynamic segments in a location path. For example, if we look at the path `"/blog/35/category/movies/date/2023-12-28"` using the route definition below, we can find three dynamic segments: `"35"`,`"movies"`, and `"2023-12-28"`. 8 | 9 | ``` ts 10 | import { createRoutes, int, str, date, parsePath } from "typesafe-routes"; 11 | 12 | const routes = createRoutes({ 13 | blog: { 14 | path: ["blog", int("blogId")], 15 | children: { 16 | categories: { 17 | path: ["category", str.optional("category")], 18 | children: { 19 | date: { 20 | path: ["date", date("date")], 21 | }, 22 | } 23 | } 24 | } 25 | } 26 | }); 27 | ``` 28 | 29 | 30 | ### **Parameter Record** 31 | 32 | Frameworks such as [React Router](https://reactrouter.com) provide a [record](https://reactrouter.com/en/main/hooks/use-params#useparams) containing parameter values. These parameters, typically in string format, can be processed using the `parsePath` function. This method transforms these string values into their respective JavaScript types, enhancing data handling within the application. 33 | 34 | ``` ts 35 | // this object might be provided by a routing library 36 | const params = { 37 | blogId: "35", 38 | category: "movies", 39 | date: "2023-12-28", 40 | }; 41 | 42 | parsePath(routes.blog.categories.date, params); 43 | // ~> { 44 | // blogId: 35, 45 | // category: "movies", 46 | // date: Date("2023-12-28T00:00:00.000Z") 47 | // } 48 | ``` 49 | 50 | The `params` object's string-based values are all converted to the corresponding type that was previously defined with `createRoutes`. 51 | 52 | ### **Relative Routes** 53 | 54 | `parsePath` is also able to handle [relative route](basic-features/relative-routes.md) paths initiated with the `_` link. 55 | 56 | ``` ts 57 | 58 | parsePath(routes.blog._.categories.date, { 59 | category: "movies", 60 | date: "2023-12-28", 61 | }); // ~> { category: "movies", date: Date("2023-12-28T00:00:00.000Z") } 62 | 63 | parsePath(routes.blog.categories._.date, { 64 | date: "2023-12-28", 65 | }); // ~> { date: Date("2023-12-28T00:00:00.000Z") } 66 | ``` 67 | 68 | ### **Absolute Location Path** 69 | 70 | Alternatively, parsing parameters from string paths, like `location.pathname`, is also supported. 71 | 72 | ``` ts 73 | // with absolute path location 74 | parsePath( 75 | routes.blog.categories.date, 76 | "/blog/35/category/movies/date/2023-12-28" // location.path 77 | ); // ~> { blogId: 35, catId: "movies", date: Date("2023-12-28T00:00:00.000Z") } 78 | 79 | // omitting optional parameters 80 | parsePath( 81 | routes.blog.categories.date, 82 | "/blog/35/category/date/2023-12-28" // without the "category" parameter 83 | ); // ~> { blogId: 35, date: Date("2023-12-28T00:00:00.000Z") } 84 | ``` 85 | 86 | ### **Relative Location Path** 87 | 88 | It is also possible to parse parameters from [relative route](basic-features/relative-routes.md) paths that are initiated with `_`. 89 | 90 | ``` ts 91 | // with relative location path 92 | parsePath( 93 | routes.blog._.categories.date, 94 | "category/date/2023-12-28" 95 | ); // ~> { date: Date("2023-12-28T00:00:00.000Z") } 96 | ``` 97 | 98 | 99 | ## Query Parameters 100 | 101 | The `parseQuery` function converts [search parameter](https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams) values from string format to their corresponding types. It takes one argument that is the source, which contains the string-based parameter values that need to be parsed. A source can be an object with string values `{name: "value",...}` or a search string `"?name=value&..."`. 102 | 103 | ``` ts 104 | import { createRoutes, int, bool, date } from "typesafe-routes"; 105 | 106 | const routes = createRoutes({ 107 | blog: { 108 | path: ["blog"], 109 | query: [int("page")] 110 | children: { 111 | categories: { 112 | path: ["category"], 113 | query: [date.optional("date")] 114 | children: { 115 | options: { 116 | query: [bool("showModal")] 117 | }, 118 | } 119 | } 120 | } 121 | } 122 | }); 123 | ``` 124 | 125 | Note that in the example the `options` node is a regular route node but lacks a `path` property, indicating that this node is exclusively used for handling query parameters. 126 | 127 | 128 | ### **Basic Usage** 129 | ``` ts 130 | // this could come from a router library: 131 | const params = { page: "1", showModal: "false" }; 132 | 133 | parseQuery(route.blog.categories.options, params); // ~> { page: 1, showModal: false } 134 | ``` 135 | 136 | ### **Relative Routes** 137 | 138 | [Relative Routes](basic-features/relative-routes.md) are compatible with the parsing of query parameters, causing `parseQuery` to parse only those parameters that belong to the routes that are initiated with `_`. 139 | 140 | ``` ts 141 | parseQuery(route.blog._.categories.options, { 142 | showModal: "false", 143 | }); // ~> { showModal: false } 144 | ``` 145 | 146 | ### **String-Based Source** 147 | 148 | ``` ts 149 | // absolute route path 150 | parseQuery( 151 | route.blog.categories.options, 152 | "?catId=movies&date=2023-12-28&showModal=false" 153 | ); // ~> { catId: "movies", date: Date("2023-12-28T00:00:00.000Z"), showModal: false } 154 | 155 | // relative route path 156 | parseQuery( 157 | route.blog._.categories.options, 158 | "?date=2023-12-28&showModal=false" 159 | ); // ~> { date: Date("2023-12-28T00:00:00.000Z"), showModal: false } 160 | ``` 161 | 162 | ### **Unknown Params** 163 | 164 | Parameters that are not specified in any of the route nodes will not be included in the parsing result. This means that only parameters defined within the route nodes are processed. 165 | 166 | ``` ts 167 | // ignores addional parameters 168 | parseQuery( 169 | route.blog.categories, 170 | "?page=5&a=123&b=456" 171 | ); // => { page: 5 } // does not include "a" and "b" because they were not specified with `createRoutes` 172 | ``` 173 | 174 | 175 | ## Safe Parsing 176 | 177 | Parsing libraries, such as [Zod](https://github.com/colinhacks/zod/blob/3032e240a0c227692bb96eedf240ed493c53f54c/README.md#safeparse), provide a method for safe parsing that doesn't trigger an error when validation fails. Typesafe-routes includes similar safe call functions, `safeParsePath`, `safeParseQuery`, and `safeParse`, which return an object containing information about the parsing result. 178 | 179 | ``` ts 180 | import { safeParseQuery } from "typesafe-routes"; 181 | 182 | safeParseQuery(route.blog.categories, "?page=5"); 183 | // => { success: true; data: { page: 5 }} 184 | 185 | safeParseQuery(route.blog.categories, "?offset=10"); 186 | // => { success: false; error: Error } 187 | ``` -------------------------------------------------------------------------------- /docs/basic-features/parameter-types.md: -------------------------------------------------------------------------------- 1 | # Parameter Types 2 | 3 | This section contains a list of parameter types that are included with the library. Refer to the [Custom Parameter Types](customization/custom-parameter-types.md) section to learn how to register parsers and serializers for other types. 4 | 5 | 6 | ## **str** 7 | ``` ts 8 | import { createRoutes, str, renderPath, parsePath } from "typesafe-routes"; 9 | 10 | const routes = createRoutes({ 11 | myRoute: { 12 | path: ["path", str("lang")] 13 | } 14 | }); 15 | 16 | renderPath(routes.myRoute, { lang: "en" }); // ~> "/path/en" 17 | parsePath(routes.myRoute, "/path/en"); // ~> { lang: "en" } 18 | ``` 19 | 20 | ## **int** 21 | 22 | ``` ts 23 | import { createRoutes, int, renderPath, parsePath } from "typesafe-routes"; 24 | 25 | const routes = createRoutes({ 26 | myRoute: { 27 | path: ["path", int("id")] 28 | } 29 | }); 30 | 31 | renderPath(routes.myRoute, { id: 55 }); // ~> "/path/55" 32 | parsePath(routes.myRoute, "/path/55"); // ~> { id: 55 } 33 | ``` 34 | 35 | ## **float** 36 | 37 | ``` ts 38 | import { createRoutes, float, renderPath, parsePath } from "typesafe-routes"; 39 | 40 | const f2 = float(2); // renders 2 fraction digits 41 | 42 | const routes = createRoutes({ 43 | myRoute: { 44 | path: ["path", f2("x")] 45 | } 46 | }); 47 | 48 | renderPath(routes.myRoute, { x: 55.1234 }); // ~> "/path/55.12" 49 | parsePath(routes.myRoute, "/path/55.12"); // ~> { x: 55.12 } 50 | ``` 51 | 52 | ## **isoDate** 53 | 54 | ``` ts 55 | import { createRoutes, isoDate, renderPath, parsePath } from "typesafe-routes"; 56 | 57 | const routes = createRoutes({ 58 | myRoute: { 59 | path: ["path", isoDate("date")] 60 | } 61 | }); 62 | 63 | renderPath(routes.myRoute, { date: new Date(1706549242302) }); // ~> "/path/2024-01-29T17:27:22.302Z" 64 | parsePath(routes.myRoute, "/path/2024-01-29T17:27:22.302Z"); // ~> { date: Date("2024-01-29T17:27:22.302Z") } 65 | ``` 66 | 67 | ## **date** 68 | 69 | ``` ts 70 | import { createRoutes, date, renderPath, parsePath } from "typesafe-routes"; 71 | 72 | const routes = createRoutes({ 73 | myRoute: { 74 | path: ["path", date("date")] 75 | } 76 | }); 77 | 78 | renderPath(routes.myRoute, { date: new Date(1706549242302) }); // ~> "/path/2024-01-29" 79 | parsePath(routes.myRoute, "/path/2024-01-29"); // ~> { date: Date("2024-01-29") } 80 | ``` 81 | 82 | ## **bool** 83 | 84 | ``` ts 85 | import { createRoutes, bool, renderPath, parsePath } from "typesafe-routes"; 86 | 87 | const routes = createRoutes({ 88 | myRoute: { 89 | path: ["path", bool("isVisible")] 90 | } 91 | }); 92 | 93 | renderPath(routes.myRoute, { isVisible: true }); // ~> "/path/true" 94 | parsePath(routes.myRoute, "/path/false"); // ~> { isVisible: false } 95 | ``` 96 | 97 | ## **oneOf** 98 | 99 | ``` ts 100 | import { createRoutes, oneOf, renderPath, parsePath } from "typesafe-routes"; 101 | 102 | const options = oneOf("movies", "music", "art") 103 | 104 | const routes = createRoutes({ 105 | myRoute: { 106 | path: ["path", options("category")] 107 | } 108 | }); 109 | 110 | renderPath(routes.myRoute, { category: "music" }); // ~> "/path/music" 111 | parsePath(routes.myRoute, "/path/art"); // ~> { category: "art" } 112 | ``` 113 | 114 | ## **list** 115 | 116 | ``` ts 117 | import { createRoutes, list, renderPath, parsePath } from "typesafe-routes"; 118 | 119 | const options = list(["movies", "music", "art"], ","); // second argument is optional, default is ";" 120 | 121 | const routes = createRoutes({ 122 | myRoute: { 123 | path: ["path", options("categories")] 124 | } 125 | }); 126 | 127 | renderPath(routes.myRoute, { categories: ["music", "art"] }); // ~> "/path/music,art" 128 | parsePath(routes.myRoute, "/path/art,movies,music"); // ~> { categories: ["art", "movies", "music"] } 129 | ``` 130 | 131 | -------------------------------------------------------------------------------- /docs/basic-features/parameters.md: -------------------------------------------------------------------------------- 1 | # Parameters 2 | 3 | ## Path Parameters 4 | 5 | In addition to static path segments, `path` segment arrays can also accommodate dynamic segments, referred to as parameters. Parameters are named, have specific types, and are equipped with parser and serializer implementations to facilitate string conversion. Parameters can be designated as `optional`, ensuring that no exceptions are raised if optional parameters are absent during rendering or parsing processes. 6 | 7 | The example showcases the import of string (`str`) and integer (`int`) parameter functions for defining typed parameters. For details on other built-in parameter types, please refer to the [Parameter Types](basic-features/parameter-types.md) section. 8 | 9 | ``` ts 10 | import { createRoutes, renderPath, str, int } from "typesafe-routes"; 11 | 12 | const routes = createRoutes({ 13 | blog: { 14 | // path contains static and dynamic segments (parameters) 15 | path: ["blog", "categories", str("category"), "year", int.optional("year")] 16 | } 17 | }); 18 | ``` 19 | 20 | 21 | ### **Required Params Only** 22 | ``` ts 23 | renderPath(routes.blog, { 24 | category: "movies" // required parameter 25 | }); // ~> "/blog/categories/movies/year" 26 | ``` 27 | 28 | ### **With Optional Params** 29 | ``` ts 30 | renderPath(routes."blog", { 31 | category: "movies", 32 | year: 2024, // optional parameter 33 | }); // ~> "/blog/categories/movies/year/2024" 34 | ``` 35 | 36 | ### **Nested Segments** 37 | 38 | When supplying nested segment names, include all parameters in a unified object. For improved clarity in assigning parameters. 39 | 40 | ``` ts 41 | renderPath(routes.segmentA.segmentB, { 42 | // unified parameter object for "segmentA" and "segmentB" 43 | paramA: "param-a", 44 | paramB: "param-b", 45 | }); // ~> "/segment-a/param-a/segment-b/param-b" 46 | ``` 47 | 48 | 49 | 50 | ## Query parameters 51 | 52 | Query parameters can be defined by setting a `query` property in a route node. Query parameters can be made `optional`. If an `optional` query parameter is missing during rendering or parsing, no error will be thrown. 53 | 54 | ``` ts 55 | import { createRoutes, renderQuery, str, int, bool } from "typesafe-routes"; 56 | 57 | const routes = createRoutes({ 58 | blog: { 59 | path: ["blog"], 60 | query: [str("search"), int.optional(page), bool.optional("filter")] 61 | } 62 | }); 63 | ``` 64 | 65 | The `renderQuery` function concatenates the entire query string following the given path. Query parameters are fully compatible with [Nested Routes](basic-features/nested-routes.md) but [Relative Routes](basic-features/relative-routes.md) don't apply on query params. Required query params can't be omitted if they belong to a parent route of a relative path. If you don't like this behaviour you can switch to optional params or move your required query param to a childless sibling node in a different branch. 66 | 67 | 68 | ### **Required Params Only** 69 | ``` ts 70 | renderQuery(routes.blog, { 71 | search: "batman", 72 | }); // ~> "search=batman" 73 | ``` 74 | 75 | ### **With Optional Params** 76 | 77 | ``` ts 78 | renderQuery(routes.blog, { 79 | search: "batman", 80 | page: 0, 81 | filter: true, 82 | }); // ~> "search=batman&page=0&filter=true" 83 | ``` 84 | 85 | ### **Nested Segments** 86 | 87 | When supplying nested segment names, include all parameters in a unified object. 88 | 89 | ``` ts 90 | renderQuery(routes.segmentA.segmentB, { 91 | // unified parameter object for "segmentA" and "segmentB" 92 | paramA: "valueA", 93 | paramB: "valueB", 94 | }); // ~> "paramA=valueA¶mB=valueB" 95 | ``` 96 | 97 | 98 | 99 | ## Mixing path & query parameters 100 | 101 | Path and query can be rendered in one single step using the `render` function. 102 | 103 | ``` ts 104 | import { createRoutes, render, str, int, bool } from "typesafe-routes"; 105 | 106 | const routes = createRoutes({ 107 | blog: { 108 | // path contains static and dynamic segments (parameters) 109 | path: ["blog", "categories", str("category"), "year", int.optional("year")] 110 | query: [str("search"), int.optional(page), bool.optional("filter")] 111 | } 112 | }); 113 | 114 | render(routes.blog, { 115 | path: { category: "movies" }, 116 | query: { search: "robocop" }, 117 | }); // ~> /blog/categories/movies/year?search=robocop 118 | ``` 119 | 120 | The second argument of `render` takes an object with the properties `path` and `query`, containing parameter records that correspond to the route definitons above. 121 | 122 | 123 | ## Type inference 124 | 125 | Query parameter types can be extraced with `InferQueryParams`, `InferPathParams` and `InferParams`. 126 | 127 | The next example shows how to use `InferQueryParams`. The remaining utility types can be used similarly. 128 | 129 | ``` ts 130 | import type { InferQueryParams } from "typesafe-routes"; 131 | 132 | type MyParams = InferQueryParams; 133 | 134 | const params: MyParams = { 135 | search: "batman", 136 | page: 0, 137 | filter: true, 138 | } // ✅ complies 139 | 140 | const params: MyParams = {} // ❌ TypeError (search is not optional) 141 | ``` 142 | -------------------------------------------------------------------------------- /docs/basic-features/relative-routes.md: -------------------------------------------------------------------------------- 1 | # Relative routes 2 | 3 | The 'relative routes' feature is used for generating subpaths. By default, absolute paths are prefixed with a leading `/` and relative paths are rendered without this prefix. To initiate a relative path, the underscore `_` link can be used to specify the starting segment. 4 | 5 | You can modify this default behavior using a custom renderer. Refer to the [Custom Path Rendering](customization/custom-path-rendering.md) section for more examples. 6 | 7 | ``` ts 8 | import { createRoutes, renderPath } from "typesafe-routes"; 9 | 10 | const routes = createRoutes({ 11 | blog: { 12 | path: ["blog"], 13 | children: { 14 | categories: { 15 | path: ["categories"], 16 | children: { 17 | year: { 18 | path: ["music"] 19 | }, 20 | } 21 | } 22 | } 23 | } 24 | }); 25 | ``` 26 | 27 | 28 | ## **Absolute Routes** 29 | 30 | By default an absolute path contains a leading `/` character. 31 | 32 | ``` ts 33 | renderPath(routes.blog.categories.music, {}); // ~> "/blog/categories/music" 34 | ``` 35 | 36 | ## **Relative Routes** 37 | 38 | Relative paths don't start with a leading `/` character. 39 | 40 | ``` ts 41 | renderPath(routes.blog._.categories, {}); // ~> "categories" (a relative path without the leading "/blog" path segment) 42 | 43 | renderPath(routes.blog._.categories.music, {}); // ~> "categories/music" 44 | 45 | renderPath(routes.blog.categories._.music, {}); // ~> "music" (two route nodes were omitted) 46 | ``` 47 | 48 | -------------------------------------------------------------------------------- /docs/basic-features/route-templates.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | The `template` function is capable of generating template path strings (like `"projects/:projectId/tasks/:taskId"`) that can be used with routers such as [React Router](https://reactrouter.com), [Vue Router](https://router.vuejs.org/), [Angular Router](https://angular.dev/guide/routing), and more. 4 | 5 | Given the following route tree with a parent node `blog` and a child `categories`: 6 | 7 | ``` ts 8 | import { createRoutes, str, template } from "typesafe-routes"; 9 | 10 | const routes = createRoutes({ 11 | blog: { 12 | path: ["blog"], 13 | children: { 14 | categories: { 15 | path: ["categories", str("category"), str.optional("year")], 16 | }, 17 | } 18 | } 19 | }); 20 | ``` 21 | 22 | 23 | ### **Absolute Paths** 24 | 25 | By default absolute path templates are rendered with a leading `/`. Dynamic segments or parameters are rendered with a colon `:` prefix. Optional parameters are indicated with a `?` suffix in the resulting template. You can personalize template rendering using a custom renderer. For examples, refer to the [Custom Template Rendering](customization/custom-template-rendering.md) section. 26 | 27 | ``` ts 28 | template(routes.blog.categories); // ~> "/blog/categories/:category/:year?" 29 | ``` 30 | 31 | ### **Relative Paths** 32 | 33 | Initiate relative route templates with an `_` link, similar to how [Relative Routes](basic-features/relative-routes.md) are rendered with the `render` functions. 34 | 35 | ``` ts 36 | template(routes.blog.categories); // ~> "/blog/categories/:category/:year?" 37 | template(routes.blog._.categories); // ~> "categories/:category/:year?" 38 | ``` 39 | 40 | 41 | 42 | ## Template Property 43 | 44 | In the examples above, templates are rendered based on the path definitions in the route tree. However, in some edge cases, overriding certain segments in the rendered templates might be necessary. This could be because you want to use advanced features of your routing library, such as wildcards or [regex statements for parameters](https://github.com/lukeed/regexparam). For these use cases, the `template` property comes in handy. 45 | 46 | The following example shows a route tree with a parent node `blog` and the three children: `anything`, `categories`, and `movies`. 47 | 48 | 49 | ``` ts 50 | const routes = createRoutes({ 51 | blog: { 52 | path: ["blog"], 53 | children: { 54 | anything: { 55 | template: "**" 56 | }, 57 | categories: { 58 | path: [ 59 | "categories", 60 | str("cid") 61 | ], 62 | template: "categories/:cid-(movies|music|art)", 63 | }, 64 | movies: { 65 | path: [ 66 | "movies", 67 | str("title", { template: ":title.(mp4|mov)" }), 68 | ], 69 | } 70 | } 71 | } 72 | }); 73 | ``` 74 | 75 | 76 | ### **Route Templates** 77 | For some edge cases like wildcards (`**` in [Angular Router](https://v17.angular.io/guide/router#setting-up-wildcard-routes)) or the star sign (`*` in [React Router](https://reactrouter.com/start/library/routing#splats)), use the pass-through `$template` property to pass any string to the resulting template. Only the `template` function can render route segments with a `template` property; They are ignored by the `render`, `renderPath`, and `renderQuery` functions. 78 | 79 | ``` ts 80 | template(routes.blog.wildcard); // ~> "/blog/**" 81 | ``` 82 | 83 | The second example renders a template for the categories route. Note that we also specified a path `["categories", str("cid")]` to make the template compatible with other methods such as `$render`, `$parseParams`, etc. 84 | 85 | ``` ts 86 | template(routes.blog.categories); // ~> "/blog/categories/:cid-(movies|music|art)" 87 | ``` 88 | 89 | ### **Parameter Templates** 90 | 91 | We can also assign templates to parameters individually. In our route definition, we specified the title parameter with `str("title", { template: ":title.(mp4|mov)" })`. The template property is set to `":title.(mp4|mov)"` to limit the file extension to either `mp4` or `mov`. 92 | 93 | ``` ts 94 | template(routes.blog.movies); // ~> "/blog/movies/:title.(mp4|mov)" 95 | ``` 96 | 97 | -------------------------------------------------------------------------------- /docs/customization/custom-parameter-types.md: -------------------------------------------------------------------------------- 1 | # Custom Parameter Types 2 | 3 | The `param` helper facilitates the easy creation of custom parameters when default ones such as `str`, `int`, or `bool` do not fully meet your application's requirements. It requires two methods: one for serialization and another for parsing. In JavaScript, you can disregard type annotations. However, in TypeScript, they are necessary as the parameter types are inferred from them. 4 | 5 | ``` ts 6 | const myParam = param({ 7 | serialize: (value: MyType) => stringify(value), 8 | parse: (value: MyType) => parse(value), 9 | }); 10 | ``` 11 | 12 | Here are more examples: 13 | 14 | 15 | ## **TypeScript** 16 | 17 | ``` ts 18 | import { createRoutes, param, render } from "typesafe-routes"; 19 | 20 | interface Pos { 21 | lat: number 22 | lon: number 23 | } 24 | 25 | const pt = param({ 26 | serialize: (value: Point) => JSON.stringify(value), 27 | parse: (value: string) => JSON.parse(value), 28 | }); 29 | 30 | const routes = createRoutes({ 31 | map: { 32 | path: ["map"], 33 | query: [pt("coordinates")] 34 | } 35 | }); 36 | 37 | render(routes.map, { 38 | path: {}, 39 | query: { 40 | coordinates: { 41 | lat: 51.386998452, 42 | lon: 30.092666296, 43 | } 44 | } 45 | }) // ~> "/map?coordinates=%7B%22lat%22:51.386998452,%22lon%22:30.092666296%7D" 46 | ``` 47 | 48 | ## **JavaScript** 49 | 50 | ``` js 51 | import { createRoutes, param, render } from "typesafe-routes"; 52 | 53 | const pt = param({ 54 | serialize: (value) => JSON.stringify(value), 55 | parse: (value) => JSON.parse(value), 56 | }); 57 | 58 | const routes = createRoutes({ 59 | map: { 60 | path: ["map"], 61 | query: [pt("coordinates")] 62 | } 63 | }); 64 | 65 | render(routes.map, { 66 | path: {}, 67 | query: { 68 | coordinates: { 69 | lat: 51.386998452, 70 | lon: 30.092666296, 71 | } 72 | } 73 | }); // ~> "/map?coordinates=%7B%22lat%22:51.386998452,%22lon%22:30.092666296%7D" 74 | ``` 75 | 76 | ## **Paramaterized Params** 77 | 78 | ``` ts 79 | import { createRoutes, param, render } from "typesafe-routes"; 80 | 81 | const float = (fractionDigits?: number) => 82 | param({ 83 | parse: (value: string) => parseFloat(value), 84 | serialize: (value: number) => value.toFixed(fractionDigits), 85 | }); 86 | 87 | const f2 = float(2); 88 | 89 | const routes = createRoutes({ 90 | video: { 91 | path: ["video"], 92 | query: [f2("time")], 93 | } 94 | }); 95 | 96 | render(routes.video, { 97 | path: {}, 98 | query: { time: 13.3745 }, 99 | }) // ~> "/video?time=13.37" 100 | ``` 101 | 102 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | typesafe-routes 6 | 7 | 8 | 9 | 15 | 16 | 17 | 18 |
19 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /docs/tutorials/angular-router.md: -------------------------------------------------------------------------------- 1 | # Eliminate Runtime Errors with Type-safe Routes in Angular 2 | 3 | Refactoring an Angular application can be a double-edged sword. On one hand, it allows you to improve the maintainability and scalability of your codebase. On the other hand, it can lead to broken routes if you haven't taken the necessary precautions to protect your features against unintended changes. Writing extensive tests or implementing a solid typing concept for routes can help mitigate this risk, but these approaches can be time-consuming and may not always be feasible. In this article, we'll explore a more efficient solution that automatically detects broken routes in compile-time, without requiring manual test efforts or the need to write custom type annotations. We'll demonstrate this approach by implementing a sample Angular application with nested components and using the `typesafe-routes` library to improve developer experience and facilitate parameter parsing. 4 | 5 | To illustrate the benefits of automatically detecting broken routes in compile-time, we'll implement a sample Angular application with three nested components: `DashboardComponent` (`/dashboard`), `OrgsComponent` (`/orgs/:orgId`), and `LocationsComponent` (`/orgs/:orgId/locations/:locationId`). To set up this example, we'll need to install the `typesafe-routes` library and use its `createRoutes` function to define our route tree, as shown in the following code fragment. 6 | 7 | ```ts 8 | // app.routes.ts 9 | import { createRoutes, int } from "typesafe-routes"; 10 | 11 | export const r = createRoutes({ 12 | dashboard: { 13 | path: ["dashboard"], // ~> "/dashboard" 14 | }, 15 | orgs: { 16 | path: ["orgs", int("orgId")], // ~> "/orgs/:orgId" 17 | children: { 18 | locations: { 19 | path: ["locations", int("locationId")], // ~> "locations/:locationId" 20 | query: [int.optional("page")], // ~> "?page=[number]" 21 | }, 22 | }, 23 | }, 24 | }); 25 | ``` 26 | 27 | Let's take a closer look at the code fragment. We import `createRoutes` from `typesafe-routes` and pass on our routes as its first argument. These routes are defined as a nested object with two properties at the root level: `dashboard` and `orgs`. Each of these properties is assigned a `path`, specifying the segments in the form of an array. For example, the `["dashboard"]` array corresponds to the path `/dashboard`. The `orgs` path is more complex, as it contains a parameter named `orgId` of type `integer`. Note that `integer` is not a native JavaScript type, but rather a custom type defined using the `int` function, which mimics the characteristics of an integer using a `number` in the background. The `orgs` route has a `children` property, which specifies one child route called `locations`. The `locations` route is similar to the `orgs` route, but it specifies an additional optional [search parameter](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) `page` of type `int`. 28 | 29 | 30 | `createRoutes` uses the information about the routes to create a context wrapped in a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) object. You don't need to know the details about that proxy object, but it's essential to understand that thanks to that object, you can access all routes specifications anywhere in your application to render and parse routes and parameters. 31 | 32 | We assigned the Proxy object returned by `createRoutes` to `r`. This means you can access the `dashboard` path with `r.dashboard`, `locations` path with `r.orgs.locations`, and so on. 33 | 34 | ## Rendering Templates 35 | 36 | With our routes defined, we can now move on to the next step: registering them with `angular-router`. 37 | 38 | ```ts 39 | // app.routes.ts 40 | import { Routes } from "@angular/router"; 41 | import { template } from "typesafe-routes/angular-router"; 42 | 43 | export const routes: Routes = [ 44 | { 45 | path: template(r.dashboard), // ~> "dashboard" 46 | component: DashboardComponent, 47 | }, 48 | { 49 | path: template(r.orgs), // ~> "orgs/:orgId" 50 | component: OrgsComponent, 51 | children: [ 52 | { 53 | path: template(r.orgs._.locations), // ~> "locations/:locationId" 54 | component: LocationsComponent, 55 | }, 56 | ], 57 | }, 58 | ]; 59 | ``` 60 | 61 | The code fragment shows a common setup with nested routes for Angular Router that mirrors the route tree we defined earlier. However, instead of using typical plain strings to specify the `path` templates (for instance `orgs/:orgId`), we import the `template` function from `typesafe-routes/angular-router` and use it to generate the path templates. For the `DashboardComponent` and `OrgsComponent`, we can simply call `template` with their corresponding paths `r.dashboard` and `r.orgs` to get the templates. However, the remaining component `LocationsComponent` is a child of `OrgsComponent` and thus requires a relative path, which cannot be generated by using `r.orgs.locations` as this would result in an absolute path `orgs/:orgId/locations/:locationId`, whereas Angular Router expects a relative path when nesting route templates. 62 | 63 | To generate a relative path, we can use the `_` link, which effectively omits everything that precedes the underscore character. In this case, we can use `template(r.orgs._.locations)` to generate the relative path. This is a handy feature, as it allows us to reuse the same route tree in scenarios where we need to render absolute paths but also in situations that require a relative path. 64 | 65 | At this point we already took advantage of autocompletion and typo prevention in our favourite IDE (such as [Visual Studio Code](https://code.visualstudio.com/)). And future changes will alert us to any misspelling or typos in our route paths because all types can be traced back to the initial routes definition with `createRoutes`. 66 | 67 | ## Rendering Links 68 | 69 | Now that we have specified our route templates, we want to move on to link rendering. For that, we want to create a simple component that utilizes render functions to render those links, including type serialization and type checks. The next example shows a component that renders a list of anchor elements referencing other components in our application. 70 | 71 | ```ts 72 | // app.component.ts 73 | import { render, renderPath } from "typesafe-routes/angular-router"; 74 | import { r } from "./app.routes"; 75 | 76 | @Component({ 77 | selector: "app-root", 78 | imports: [RouterOutlet, RouterLink], 79 | template: ` 80 |

Absolute Links

81 | 90 | 91 | `, 92 | }) 93 | export class AppComponent { 94 | dashboardLink = renderPath(r.dashboard, {}); // ~> dashboard 95 | orgsLink = renderPath(r.orgs, { orgId: 123 }); // ~> orgs/123 96 | 97 | locationLink = render(r.orgs.locations, { 98 | path: { orgId: 321, locationId: 654 }, 99 | query: { page: 42 }, 100 | }); // ~> { path: "orgs/321/location/654", query: { page: "42" }} 101 | } 102 | // ... 103 | ``` 104 | 105 | The code example imports `render` and `renderPath` from `typesafe-routes/angular-router`. `renderPath` renders a path, whereas `render` additionally serializes the query parameters for our link list. We also import `r`, the proxy object that allows us to access the information about the previously defined routes and to define the desired route to be rendered. 106 | 107 | First, we create `dashboardLink` and `orgsLink` using the `renderPath` function. As the first parameter, it takes the aforementioned proxy object representing the path of the route to be rendered. The second parameter is a record with parameter values matching the name and type of the parameter previously defined with `createRoutes` in `app.routes.ts`. The return value is a string containing the path belonging to the corresponding component. 108 | 109 | The `render` function in the third example renders both path and search parameters, and thus requires a `path` and a `query` property in the parameter definitions. The return value here is an object with the two properties `path` and `query`. We set the two properties as the values of the `[routerLink]` and `[queryParams]` attributes. 110 | 111 | ## Parsing Parameters 112 | 113 | Parameter parsing is an essential part of `typesafe-routes`. During route definition above, we defined a couple of parameters and gave them an integer-like type `int`. However, since the parameter values come from various sources such as the [Location](https://developer.mozilla.org/en-US/docs/Web/API/Location) object, they are `string`-based. Conveniently, `typesafe-routes` exports helper functions that parse these strings and cast them to the desired type. Parsing is based on our proxy object `r` we created earlier, meaning we have to tell the library what route the params belong to. The next example demonstrates that by showing two common parsing scenarios. 114 | 115 | ``` ts 116 | import { r } from "./app.routes"; 117 | import { parsePath, parseQuery } from "typesafe-routes"; 118 | 119 | parseQuery( 120 | r.orgs.locations, // absolute path 121 | this.route.snapshot.queryParams // { page: "5" } // string value 122 | ); // ~> { page: 5 } 123 | 124 | parsePath( 125 | r.orgs._.locations, // relative path 126 | this.route.snapshot.params, // { orgId: "1", locationId: "2" } // string value 127 | ); // ~> { locationId: 2 } // number value 128 | ``` 129 | 130 | Given the `location.href` `orgs/1/location/2?page=5`, in Angular, we can access string-based query params using `this.route.snapshot.queryParams` and string-based path parameters are provided via `this.route.snapshot.params`. Using `parseQuery` with `r.orgs.locations` and `this.route.snapshot.queryParams`, we can retrieve an object with the `page` parameter as a `number`. Using `parsePath` with `r.orgs._.locations` and `this.route.snapshot.params`, we get the parsed `locationId`. In this case, `r.orgs._.locations` is a relative path, and all the segments before the `_` link are omitted, causing `orgId` not to be present in the resulting object. 131 | 132 | The parsing functions in `typesafe-routes` are versatile, and we can also extract all the parameters directly from the `location.href` string at once using `parse`. 133 | 134 | ``` ts 135 | import { parse } from "typesafe-routes"; 136 | 137 | parse( 138 | r.orgs.locations, 139 | location.href, // orgs/1/location/2?page=5 140 | ); // ~> { query: { orgId: 1, locationId: 2 }, query: { page: 5 }} 141 | ``` 142 | 143 | Extracting type information about parameters is possible via `InferQueryParams`, `InferPathParams`, or `InferParams`. Here is a demonstration of the `InferQueryParams` utility type. 144 | 145 | ``` ts 146 | import { InferQueryParams } from "typesafe-routes"; 147 | 148 | type QueryParams = InferQueryParams; // { page: number } 149 | 150 | const queryParams: QueryParams = { page: 123 }; // ✅ 151 | const queryParams: QueryParams = { page: "123" }; // ❌ string can't be assigned to a number prop 152 | ``` 153 | 154 | ## Wrapping Up 155 | 156 | To conclude this tutorial, we have created a single routes tree `r` that is the single source of truth for our routes. Based on that, we rendered templates that we used to register our components with Angular Router. We rendered paths with dynamic path segments and query parameters. We parsed parameters to convert them from string values to their corresponding types. We did everything in a type-safe manner without writing even one single type definition. We have established a robust routes tree that easily prevents bugs while developing new features and furthermore facilitates future refactorings. 157 | 158 | However, `typesafe-routes` has many more features, such as many different built-in parameter types, easy integration of custom parameter types, manipulation of subpaths, define custom template strings, and many more. Unfortunately, we can't cover them all in this tutorial, but you can read more by visiting the official documentation. 159 | 160 | ## Support the Project 161 | 162 | Of course, there are also many potential improvements that can be implemented to the examples shown in this tutorial. For example, a custom directive for link rendering that takes on a path definition based on our proxy object, such as `r.orgs.locations`. Another example is a function that automatically generates a [`Routes` array for Angular Router](https://angular.dev/guide/routing/router-reference#configuration), effectively eliminating duplicated code and the need to keep the routes in sync with our route tree created with `createRoutes` in the very first code block. 163 | 164 | However, these are just a few ways among many to contribute. The most common way is, of course, sharing, reporting bugs, or opening PRs in our GitHub repository. If you use this library and think it improves your development experience, you could also [buy me a coffee](https://buymeacoffee.com/kruschid). We also have a [Discord channel](https://discord.com/invite/BCGmvSSJBk) where you can leave feedback or ask questions. 165 | -------------------------------------------------------------------------------- /docs/tutorials/react-router.md: -------------------------------------------------------------------------------- 1 | # React Router 2 | 3 | todo... -------------------------------------------------------------------------------- /docs/tutorials/refine.md: -------------------------------------------------------------------------------- 1 | # Refine 2 | 3 | todo... -------------------------------------------------------------------------------- /docs/tutorials/vue-router.md: -------------------------------------------------------------------------------- 1 | # Vue Router 2 | 3 | todo... -------------------------------------------------------------------------------- /examples/angular-router/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /examples/angular-router/README.md: -------------------------------------------------------------------------------- 1 | # Eliminate Runtime Errors with Type-safe Routes in Angular 2 | 3 | Refactoring an Angular application can be a double-edged sword. On one hand, it allows you to improve the maintainability and scalability of your codebase. On the other hand, it can lead to broken routes if you haven't taken the necessary precautions to protect your features against unintended changes. Writing extensive tests or implementing a solid typing concept for routes can help mitigate this risk, but these approaches can be time-consuming and may not always be feasible. In this article, we'll explore a more efficient solution that automatically detects broken routes in compile-time, without requiring manual test efforts or the need to write custom type annotations. We'll demonstrate this approach by implementing a sample Angular application with nested components and using the `typesafe-routes` library to improve developer experience and facilitate parameter parsing. 4 | 5 | To illustrate the benefits of automatically detecting broken routes in compile-time, we'll implement a sample Angular application with three nested components: `DashboardComponent` (`/dashboard`), `OrgsComponent` (`/orgs/:orgId`), and `LocationsComponent` (`/orgs/:orgId/locations/:locationId`). To set up this example, we'll need to install the `typesafe-routes` library and use its `createRoutes` function to define our route tree, as shown in the following code fragment. 6 | 7 | ```ts 8 | // app.routes.ts 9 | import { createRoutes, int } from "typesafe-routes"; 10 | 11 | export const r = createRoutes({ 12 | dashboard: { 13 | path: ["dashboard"], // ~> "/dashboard" 14 | }, 15 | orgs: { 16 | path: ["orgs", int("orgId")], // ~> "/orgs/:orgId" 17 | children: { 18 | locations: { 19 | path: ["locations", int("locationId")], // ~> "locations/:locationId" 20 | query: [int.optional("page")], // ~> "?page=[number]" 21 | }, 22 | }, 23 | }, 24 | }); 25 | ``` 26 | 27 | Let's take a closer look at the code fragment. We import `createRoutes` from `typesafe-routes` and pass on our routes as its first argument. These routes are defined as a nested object with two properties at the root level: `dashboard` and `orgs`. Each of these properties is assigned a `path`, specifying the segments in the form of an array. For example, the `["dashboard"]` array corresponds to the path `/dashboard`. The `orgs` path is more complex, as it contains a parameter named `orgId` of type `integer`. Note that `integer` is not a native JavaScript type, but rather a custom type defined using the `int` function, which mimics the characteristics of an integer using a `number` in the background. The `orgs` route has a `children` property, which specifies one child route called `locations`. The `locations` route is similar to the `orgs` route, but it specifies an additional optional [search parameter](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) `page` of type `int`. 28 | 29 | 30 | `createRoutes` uses the information about the routes to create a context wrapped in a [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) object. You don't need to know the details about that proxy object, but it's essential to understand that thanks to that object, you can access all routes specifications anywhere in your application to render and parse routes and parameters. 31 | 32 | We assigned the Proxy object returned by `createRoutes` to `r`. This means you can access the `dashboard` path with `r.dashboard`, `locations` path with `r.orgs.locations`, and so on. 33 | 34 | ## Rendering Templates 35 | 36 | With our routes defined, we can now move on to the next step: registering them with `angular-router`. 37 | 38 | ```ts 39 | // app.routes.ts 40 | import { Routes } from "@angular/router"; 41 | import { template } from "typesafe-routes/angular-router"; 42 | 43 | export const routes: Routes = [ 44 | { 45 | path: template(r.dashboard), // ~> "dashboard" 46 | component: DashboardComponent, 47 | }, 48 | { 49 | path: template(r.orgs), // ~> "orgs/:orgId" 50 | component: OrgsComponent, 51 | children: [ 52 | { 53 | path: template(r.orgs._.locations), // ~> "locations/:locationId" 54 | component: LocationsComponent, 55 | }, 56 | ], 57 | }, 58 | ]; 59 | ``` 60 | 61 | The code fragment shows a common setup with nested routes for Angular Router that mirrors the route tree we defined earlier. However, instead of using typical plain strings to specify the `path` templates (for instance `orgs/:orgId`), we import the `template` function from `typesafe-routes/angular-router` and use it to generate the path templates. For the `DashboardComponent` and `OrgsComponent`, we can simply call `template` with their corresponding paths `r.dashboard` and `r.orgs` to get the templates. However, the remaining component `LocationsComponent` is a child of `OrgsComponent` and thus requires a relative path, which cannot be generated by using `r.orgs.locations` as this would result in an absolute path `orgs/:orgId/locations/:locationId`, whereas Angular Router expects a relative path when nesting route templates. 62 | 63 | To generate a relative path, we can use the `_` link, which effectively omits everything that precedes the underscore character. In this case, we can use `template(r.orgs._.locations)` to generate the relative path. This is a handy feature, as it allows us to reuse the same route tree in scenarios where we need to render absolute paths but also in situations that require a relative path. 64 | 65 | At this point we already took advantage of autocompletion and typo prevention in our favourite IDE (such as [Visual Studio Code](https://code.visualstudio.com/)). And future changes will alert us to any misspelling or typos in our route paths because all types can be traced back to the initial routes definition with `createRoutes`. 66 | 67 | ## Rendering Links 68 | 69 | Now that we have specified our route templates, we want to move on to link rendering. For that, we want to create a simple component that utilizes render functions to render those links, including type serialization and type checks. The next example shows a component that renders a list of anchor elements referencing other components in our application. 70 | 71 | ```ts 72 | // app.component.ts 73 | import { render, renderPath } from "typesafe-routes/angular-router"; 74 | import { r } from "./app.routes"; 75 | 76 | @Component({ 77 | selector: "app-root", 78 | imports: [RouterOutlet, RouterLink], 79 | template: ` 80 |

Absolute Links

81 | 90 | 91 | `, 92 | }) 93 | export class AppComponent { 94 | dashboardLink = renderPath(r.dashboard, {}); // ~> dashboard 95 | orgsLink = renderPath(r.orgs, { orgId: 123 }); // ~> orgs/123 96 | 97 | locationLink = render(r.orgs.locations, { 98 | path: { orgId: 321, locationId: 654 }, 99 | query: { page: 42 }, 100 | }); // ~> { path: "orgs/321/location/654", query: { page: "42" }} 101 | } 102 | // ... 103 | ``` 104 | 105 | The code example imports `render` and `renderPath` from `typesafe-routes/angular-router`. `renderPath` renders a path, whereas `render` additionally serializes the query parameters for our link list. We also import `r`, the proxy object that allows us to access the information about the previously defined routes and to define the desired route to be rendered. 106 | 107 | First, we create `dashboardLink` and `orgsLink` using the `renderPath` function. As the first parameter, it takes the aforementioned proxy object representing the path of the route to be rendered. The second parameter is a record with parameter values matching the name and type of the parameter previously defined with `createRoutes` in `app.routes.ts`. The return value is a string containing the path belonging to the corresponding component. 108 | 109 | The `render` function in the third example renders both path and search parameters, and thus requires a `path` and a `query` property in the parameter definitions. The return value here is an object with the two properties `path` and `query`. We set the two properties as the values of the `[routerLink]` and `[queryParams]` attributes. 110 | 111 | ## Parsing Parameters 112 | 113 | Parameter parsing is an essential part of `typesafe-routes`. During route definition above, we defined a couple of parameters and gave them an integer-like type `int`. However, since the parameter values come from various sources such as the [Location](https://developer.mozilla.org/en-US/docs/Web/API/Location) object, they are `string`-based. Conveniently, `typesafe-routes` exports helper functions that parse these strings and cast them to the desired type. Parsing is based on our proxy object `r` we created earlier, meaning we have to tell the library what route the params belong to. The next example demonstrates that by showing two common parsing scenarios. 114 | 115 | ``` ts 116 | import { r } from "./app.routes"; 117 | import { parsePath, parseQuery } from "typesafe-routes"; 118 | 119 | parseQuery( 120 | r.orgs.locations, // absolute path 121 | this.route.snapshot.queryParams // { page: "5" } // string value 122 | ); // ~> { page: 5 } 123 | 124 | parsePath( 125 | r.orgs._.locations, // relative path 126 | this.route.snapshot.params, // { orgId: "1", locationId: "2" } // string value 127 | ); // ~> { locationId: 2 } // number value 128 | ``` 129 | 130 | Given the `location.href` `orgs/1/location/2?page=5`, in Angular, we can access string-based query params using `this.route.snapshot.queryParams` and string-based path parameters are provided via `this.route.snapshot.params`. Using `parseQuery` with `r.orgs.locations` and `this.route.snapshot.queryParams`, we can retrieve an object with the `page` parameter as a `number`. Using `parsePath` with `r.orgs._.locations` and `this.route.snapshot.params`, we get the parsed `locationId`. In this case, `r.orgs._.locations` is a relative path, and all the segments before the `_` link are omitted, causing `orgId` not to be present in the resulting object. 131 | 132 | The parsing functions in `typesafe-routes` are versatile, and we can also extract all the parameters directly from the `location.href` string at once using `parse`. 133 | 134 | ``` ts 135 | import { parse } from "typesafe-routes"; 136 | 137 | parse( 138 | r.orgs.locations, 139 | location.href, // orgs/1/location/2?page=5 140 | ); // ~> { query: { orgId: 1, locationId: 2 }, query: { page: 5 }} 141 | ``` 142 | 143 | Extracting type information about parameters is possible via `InferQueryParams`, `InferPathParams`, or `InferParams`. Here is a demonstration of the `InferQueryParams` utility type. 144 | 145 | ``` ts 146 | import { InferQueryParams } from "typesafe-routes"; 147 | 148 | type QueryParams = InferQueryParams; // { page: number } 149 | 150 | const queryParams: QueryParams = { page: 123 }; // ✅ 151 | const queryParams: QueryParams = { page: "123" }; // ❌ string can't be assigned to a number prop 152 | ``` 153 | 154 | ## Wrapping Up 155 | 156 | To conclude this tutorial, we have created a single routes tree `r` that is the single source of truth for our routes. Based on that, we rendered templates that we used to register our components with Angular Router. We rendered paths with dynamic path segments and query parameters. We parsed parameters to convert them from string values to their corresponding types. We did everything in a type-safe manner without writing even one single type definition. We have established a robust routes tree that easily prevents bugs while developing new features and furthermore facilitates future refactorings. 157 | 158 | However, `typesafe-routes` has many more features, such as many different built-in parameter types, easy integration of custom parameter types, manipulation of subpaths, define custom template strings, and many more. Unfortunately, we can't cover them all in this tutorial, but you can read more by visiting the official documentation. 159 | 160 | ## Support the Project 161 | 162 | Of course, there are also many potential improvements that can be implemented to the examples shown in this tutorial. For example, a custom directive for link rendering that takes on a path definition based on our proxy object, such as `r.orgs.locations`. Another example is a function that automatically generates a [`Routes` array for Angular Router](https://angular.dev/guide/routing/router-reference#configuration), effectively eliminating duplicated code and the need to keep the routes in sync with our route tree created with `createRoutes` in the very first code block. 163 | 164 | However, these are just a few ways among many to contribute. The most common way is, of course, sharing, reporting bugs, or opening PRs in our GitHub repository. If you use this library and think it improves your development experience, you could also [buy me a coffee](https://buymeacoffee.com/kruschid). We also have a [Discord channel](https://discord.com/invite/BCGmvSSJBk) where you can leave feedback or ask questions. 165 | -------------------------------------------------------------------------------- /examples/angular-router/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-router": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:application", 15 | "options": { 16 | "outputPath": "dist/angular-router", 17 | "index": "src/index.html", 18 | "browser": "src/main.ts", 19 | "polyfills": [ 20 | "zone.js" 21 | ], 22 | "tsConfig": "tsconfig.app.json", 23 | "assets": [ 24 | { 25 | "glob": "**/*", 26 | "input": "public" 27 | } 28 | ], 29 | "styles": [], 30 | "scripts": [] 31 | }, 32 | "configurations": { 33 | "production": { 34 | "budgets": [ 35 | { 36 | "type": "initial", 37 | "maximumWarning": "500kB", 38 | "maximumError": "1MB" 39 | }, 40 | { 41 | "type": "anyComponentStyle", 42 | "maximumWarning": "4kB", 43 | "maximumError": "8kB" 44 | } 45 | ], 46 | "outputHashing": "all" 47 | }, 48 | "development": { 49 | "optimization": false, 50 | "extractLicenses": false, 51 | "sourceMap": true 52 | } 53 | }, 54 | "defaultConfiguration": "production" 55 | }, 56 | "serve": { 57 | "builder": "@angular-devkit/build-angular:dev-server", 58 | "configurations": { 59 | "production": { 60 | "buildTarget": "angular-router:build:production" 61 | }, 62 | "development": { 63 | "buildTarget": "angular-router:build:development" 64 | } 65 | }, 66 | "defaultConfiguration": "development" 67 | }, 68 | "extract-i18n": { 69 | "builder": "@angular-devkit/build-angular:extract-i18n" 70 | }, 71 | "test": { 72 | "builder": "@angular-devkit/build-angular:karma", 73 | "options": { 74 | "polyfills": [ 75 | "zone.js", 76 | "zone.js/testing" 77 | ], 78 | "tsConfig": "tsconfig.spec.json", 79 | "assets": [ 80 | { 81 | "glob": "**/*", 82 | "input": "public" 83 | } 84 | ], 85 | "styles": [ 86 | "src/styles.css" 87 | ], 88 | "scripts": [] 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /examples/angular-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-router", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve" 7 | }, 8 | "private": true, 9 | "dependencies": { 10 | "@angular/animations": "^19.0.0", 11 | "@angular/common": "^19.0.0", 12 | "@angular/compiler": "^19.0.0", 13 | "@angular/core": "^19.0.0", 14 | "@angular/forms": "^19.0.0", 15 | "@angular/platform-browser": "^19.0.0", 16 | "@angular/platform-browser-dynamic": "^19.0.0", 17 | "@angular/router": "^19.0.0", 18 | "rxjs": "~7.8.0", 19 | "tslib": "^2.3.0", 20 | "typesafe-routes": "file:../..", 21 | "zone.js": "~0.15.0" 22 | }, 23 | "devDependencies": { 24 | "@angular-devkit/build-angular": "^19.0.6", 25 | "@angular/cli": "^19.0.6", 26 | "@angular/compiler-cli": "^19.0.0", 27 | "@types/jasmine": "~5.1.0", 28 | "jasmine-core": "~5.4.0", 29 | "karma": "~6.4.0", 30 | "karma-chrome-launcher": "~3.2.0", 31 | "karma-coverage": "~2.2.0", 32 | "karma-jasmine": "~5.1.0", 33 | "karma-jasmine-html-reporter": "~2.1.0", 34 | "typescript": "~5.6.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/angular-router/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | import { RouterOutlet, RouterLink } from "@angular/router"; 3 | import { render, renderPath } from "typesafe-routes/angular-router"; 4 | import { r } from "./app.routes"; 5 | 6 | @Component({ 7 | selector: "app-root", 8 | imports: [RouterOutlet, RouterLink], 9 | template: ` 10 |

Absolute Links

11 | 20 | 21 | `, 22 | }) 23 | export class AppComponent { 24 | dashboardLink = renderPath(r.dashboard, {}); 25 | orgsLink = renderPath(r.orgs, { orgId: 123 }); 26 | locationLink = render(r.orgs.locations, { 27 | path: { orgId: 321, locationId: 654 }, 28 | query: { page: 42 }, 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /examples/angular-router/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | 4 | import { routes } from './app.routes'; 5 | 6 | export const appConfig: ApplicationConfig = { 7 | providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] 8 | }; 9 | -------------------------------------------------------------------------------- /examples/angular-router/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from "@angular/router"; 2 | import { createRoutes, int } from "typesafe-routes"; 3 | import { template } from "typesafe-routes/angular-router"; 4 | import { DashboardComponent } from "./dashboard.component"; 5 | import { LocationsComponent } from "./locations.component"; 6 | import { OrgsComponent } from "./orgs.component"; 7 | 8 | export const r = createRoutes({ 9 | dashboard: { 10 | path: ["dashboard"], // ~> "/dashboard" 11 | }, 12 | orgs: { 13 | path: ["orgs", int("orgId")], // ~> "/orgs/:orgId" 14 | children: { 15 | locations: { 16 | path: ["locations", int("locationId")], // ~> "/orgs/:orgId/locations/:locationId" 17 | query: [int.optional("page")], // ~> "?page=" 18 | }, 19 | }, 20 | }, 21 | }); 22 | 23 | export const routes: Routes = [ 24 | { 25 | path: template(r.dashboard), 26 | component: DashboardComponent, 27 | }, 28 | { 29 | path: template(r.orgs), 30 | component: OrgsComponent, 31 | children: [ 32 | { 33 | path: template(r.orgs._.locations), 34 | component: LocationsComponent, 35 | }, 36 | ], 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /examples/angular-router/src/app/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | 3 | @Component({ 4 | standalone: true, 5 | template: `

{{ name }}

`, 6 | }) 7 | export class DashboardComponent { 8 | name = "Dashboard Component"; 9 | } 10 | -------------------------------------------------------------------------------- /examples/angular-router/src/app/locations.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | import { ActivatedRoute } from "@angular/router"; 3 | import { merge } from "rxjs"; 4 | import { parsePath, parseQuery } from "typesafe-routes"; 5 | import { r } from "./app.routes"; 6 | 7 | @Component({ 8 | standalone: true, 9 | template: ` 10 |

{{ name }}

11 |

Params

12 |
{{ params }}
13 |

Query

14 |
{{ query }}
15 | `, 16 | }) 17 | export class LocationsComponent { 18 | name = "Locations Component"; 19 | params = ""; 20 | query = ""; 21 | 22 | syncParams() { 23 | this.query = JSON.stringify( 24 | parseQuery(r.orgs.locations, this.route.snapshot.queryParams) 25 | ); 26 | this.params = JSON.stringify( 27 | parsePath(r.orgs._.locations, this.route.snapshot.params) 28 | ); 29 | } 30 | 31 | constructor(private route: ActivatedRoute) {} 32 | 33 | ngOnInit() { 34 | merge(this.route.queryParamMap, this.route.paramMap).subscribe(() => 35 | this.syncParams() 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/angular-router/src/app/orgs.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | import { ActivatedRoute, RouterLink, RouterOutlet } from "@angular/router"; 3 | import { parseLocation } from "typesafe-routes"; 4 | import { render } from "typesafe-routes/angular-router"; 5 | import { r } from "./app.routes"; 6 | 7 | @Component({ 8 | standalone: true, 9 | imports: [RouterOutlet, RouterLink], 10 | template: ` 11 |

{{ name }}

12 | 15 |

Params

16 |
{{ params }}
17 | 18 | `, 19 | }) 20 | export class OrgsComponent { 21 | name = "Orgs Component"; 22 | relativeLink = render(r.orgs._.locations, { 23 | path: { locationId: 456 }, 24 | query: { page: 24 }, 25 | }); 26 | params = ""; 27 | 28 | constructor(private route: ActivatedRoute) { 29 | this.params = JSON.stringify( 30 | parseLocation(r.orgs, this.route.snapshot.params) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/angular-router/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularRouter 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/angular-router/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from "@angular/platform-browser"; 2 | import { appConfig } from "./app/app.config"; 3 | import { AppComponent } from "./app/app.component"; 4 | 5 | bootstrapApplication(AppComponent, appConfig).catch((err) => 6 | console.error(err) 7 | ); 8 | -------------------------------------------------------------------------------- /examples/angular-router/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/app", 7 | "types": [] 8 | }, 9 | "files": [ 10 | "src/main.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /examples/angular-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "outDir": "./dist/out-tsc", 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "bundler", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022" 20 | }, 21 | "angularCompilerOptions": { 22 | "enableI18nLegacyMessageIdFormat": false, 23 | "strictInjectionParameters": true, 24 | "strictInputAccessModifiers": true, 25 | "strictTemplates": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/angular-router/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": [ 8 | "jasmine" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.spec.ts", 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typesafe-routes", 3 | "version": "12.1.1-dev.0", 4 | "repository": "git@github.com:kruschid/typesafe-routes.git", 5 | "author": "Denis Kruschinski ", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "module": "dist/index.mjs", 9 | "types": "dist/index.d.mts", 10 | "exports": { 11 | ".": { 12 | "import": "./dist/index.mjs", 13 | "require": "./dist/index.js" 14 | }, 15 | "./angular-router": { 16 | "import": "./dist/adapters/angular-router.mjs", 17 | "require": "./dist/adapters/angular-router.js" 18 | } 19 | }, 20 | "typesVersions": { 21 | ">=4.5": { 22 | "angular-router": [ 23 | "dist/adapters/angular-router.d.mts", 24 | "dist/adapters/angular-router.d.ts" 25 | ] 26 | } 27 | }, 28 | "tsup": { 29 | "entry": [ 30 | "src/index.ts", 31 | "src/adapters/angular-router.ts" 32 | ], 33 | "splitting": false, 34 | "sourcemap": true, 35 | "clean": true, 36 | "format": [ 37 | "esm", 38 | "cjs" 39 | ], 40 | "dts": true 41 | }, 42 | "scripts": { 43 | "docs": "docsify serve ./docs", 44 | "build": "yarn test && yarn tsup && yarn pack && yarn attw --pack .", 45 | "test": "yarn tape -r ts-node/register test/*.ts" 46 | }, 47 | "engines": { 48 | "node": ">=20.0.0" 49 | }, 50 | "devDependencies": { 51 | "@arethetypeswrong/cli": "^0.16.1", 52 | "@types/node": "20.11.4", 53 | "@types/tape": "^5.6.4", 54 | "tape": "^5.7.3", 55 | "ts-node": "^10.9.2", 56 | "tsup": "^8.2.4", 57 | "typescript": "^4.5.0" 58 | }, 59 | "dependencies": { 60 | "ts-toolbelt": "^9.6.0" 61 | }, 62 | "peerDependencies": { 63 | "typescript": ">=4.5.0" 64 | }, 65 | "peerDependenciesMeta": { 66 | "typescript": { 67 | "optional": true 68 | } 69 | }, 70 | "files": [ 71 | "dist", 72 | "src", 73 | "LICENSE", 74 | "README.md" 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /src/adapters/angular-router.ts: -------------------------------------------------------------------------------- 1 | import { OptionalDeep } from "ts-toolbelt/out/Object/Optional"; 2 | import { 3 | InferParams, 4 | InferQueryParams, 5 | paramsFromLocationPath, 6 | paramsFromQuery, 7 | RenderPathFn, 8 | TemplateFn, 9 | WithContext, 10 | } from "../"; 11 | 12 | type RenderFn = ( 13 | route: R, 14 | params: InferParams 15 | ) => { 16 | path: string; 17 | query: Record; 18 | }; 19 | 20 | type RenderQueryFn = ( 21 | route: R, 22 | queryParams: InferQueryParams 23 | ) => Record; 24 | 25 | type ReplaceFn = ( 26 | route: R, 27 | location: string, 28 | params: OptionalDeep> 29 | ) => ReturnType; 30 | 31 | export const renderPath: RenderPathFn = ( 32 | { "~context": { relativeNodes, isRelative } }, 33 | pathParams: Record 34 | ) => { 35 | const serializedPath = relativeNodes 36 | .flatMap((node) => node.path ?? []) 37 | .flatMap((pathSegment) => 38 | // prettier-ignore 39 | typeof pathSegment === "string" ? ( 40 | pathSegment 41 | ) : pathParams[pathSegment.name] !== undefined ? ( 42 | pathSegment.parser.serialize(pathParams[pathSegment.name]) 43 | ) : [] 44 | ) 45 | .join("/"); 46 | 47 | return ( 48 | (isRelative || serializedPath.match(/^(http|https):\/\//) ? "" : "/") + 49 | serializedPath 50 | ); 51 | }; 52 | 53 | export const renderQuery: RenderQueryFn = ( 54 | { "~context": { nodes } }, 55 | queryParams: Record 56 | ) => { 57 | const serializedQueryRecord: Record = {}; 58 | 59 | nodes 60 | .flatMap((route) => route.query ?? []) 61 | .forEach(({ name, parser }) => { 62 | if (queryParams[name] !== undefined) { 63 | serializedQueryRecord[name] = parser.serialize(queryParams[name]); 64 | } 65 | }); 66 | return serializedQueryRecord; 67 | }; 68 | 69 | export const render: RenderFn = ( 70 | route, 71 | { path: pathParams, query: queryParams } 72 | ) => { 73 | return { 74 | path: renderPath(route, pathParams), 75 | query: renderQuery(route, queryParams), 76 | }; 77 | }; 78 | 79 | export const template: TemplateFn = ({ "~context": { relativeNodes } }) => { 80 | const template = relativeNodes 81 | .flatMap((node) => node.path ?? []) 82 | .map((pathSegment) => 83 | typeof pathSegment === "string" ? pathSegment : `:${pathSegment.name}` 84 | ) 85 | .join("/"); 86 | 87 | return template; 88 | }; 89 | 90 | export const replace: ReplaceFn = ( 91 | route, 92 | location, 93 | params: Record 94 | ) => { 95 | const [locationPath, locationQuery] = location.split("?"); 96 | 97 | const { pathParams, remainingSegments } = paramsFromLocationPath( 98 | route, 99 | locationPath 100 | ); 101 | 102 | const { 103 | "~context": { relativeNodes, nodes, isRelative }, 104 | } = route; 105 | 106 | const pathname = relativeNodes 107 | .flatMap((node) => node.path ?? []) 108 | .flatMap((pathSegment) => { 109 | if (typeof pathSegment === "string") return pathSegment; 110 | 111 | const { name, kind, parser } = pathSegment; 112 | 113 | if (params["path"] && name in params["path"]) { 114 | if (typeof params["path"][name] !== "undefined") 115 | return parser.serialize(params["path"][name]); 116 | 117 | if (kind === "required") { 118 | throw Error( 119 | `replace: required path param ${name} can't be set to undefined in ${template( 120 | route 121 | )}` 122 | ); 123 | } 124 | return []; 125 | } 126 | 127 | return pathParams[name] ?? []; 128 | }) 129 | .concat(remainingSegments) 130 | .join("/"); 131 | 132 | const queryParams = paramsFromQuery(locationQuery); 133 | 134 | if (params["query"]) { 135 | nodes 136 | .flatMap((node) => node.query ?? []) 137 | .forEach(({ name, parser, kind }) => { 138 | if (typeof params["query"][name] !== "undefined") { 139 | queryParams[name] = parser.serialize(params["query"][name]); 140 | } else if (name in params["query"]) { 141 | if (kind === "required") { 142 | throw Error( 143 | `replace: required query param ${name} can't be set to undefined in ${template( 144 | route 145 | )}` 146 | ); 147 | } else { 148 | delete queryParams[name]; 149 | } 150 | } 151 | }); 152 | } 153 | 154 | return { 155 | path: (isRelative ? "" : "/") + pathname, 156 | query: queryParams, 157 | }; 158 | }; 159 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./params"; 2 | export * from "./types"; 3 | export * from "./routes"; 4 | -------------------------------------------------------------------------------- /src/params.ts: -------------------------------------------------------------------------------- 1 | import type { Param, ParamOptions, Parser } from "./types"; 2 | 3 | export const param = (parser: Parser) => { 4 | const fn = ( 5 | name: N, 6 | options?: ParamOptions 7 | ): Param => ({ 8 | name, 9 | parser, 10 | kind: "required", 11 | options, 12 | }); 13 | 14 | fn.optional = ( 15 | name: N, 16 | options?: ParamOptions 17 | ): Param => ({ 18 | name, 19 | parser, 20 | kind: "optional", 21 | options, 22 | }); 23 | return fn; 24 | }; 25 | 26 | export const str = param({ 27 | parse: (value: string) => value, 28 | serialize: (value: string) => value, 29 | }); 30 | 31 | export const int = param({ 32 | parse: (value: string) => { 33 | const result = parseInt(value, 10); 34 | if (isNaN(result)) { 35 | throw new Error(`parameter value is invalid: "${value}"`); 36 | } 37 | return result; 38 | }, 39 | serialize: (value: number) => value.toString(), 40 | }); 41 | 42 | export const float = (fractionDigits?: number) => 43 | param({ 44 | parse: (value: string) => { 45 | const result = parseFloat(value); 46 | if (isNaN(result)) { 47 | throw new Error(`parameter value is invalid: "${value}"`); 48 | } 49 | return result; 50 | }, 51 | serialize: (value: number) => value.toFixed(fractionDigits), 52 | }); 53 | 54 | export const isoDate = param({ 55 | parse: (value: string) => { 56 | const timestamp = Date.parse(value); 57 | if (isNaN(timestamp)) { 58 | throw new Error(`parameter value is invalid: "${value}"`); 59 | } 60 | return new Date(timestamp); 61 | }, 62 | serialize: (value: Date) => value.toISOString(), 63 | }); 64 | 65 | export const date = param({ 66 | parse: (value: string) => { 67 | const timestamp = Date.parse(value); 68 | if (isNaN(timestamp)) { 69 | throw new Error(`parameter value is invalid: "${value}"`); 70 | } 71 | return new Date(timestamp); 72 | }, 73 | serialize: (value: Date) => value.toISOString().slice(0, 10), 74 | }); 75 | 76 | export const bool = param({ 77 | parse: (value: string) => value === "true", 78 | serialize: (value: boolean) => value.toString(), 79 | }); 80 | 81 | export const oneOf = (...list: string[]) => 82 | param({ 83 | parse: (value: string) => { 84 | if (!list.includes(value)) { 85 | throw new Error(`"${value}" is none of ${list.join(",")}`); 86 | } 87 | return value; 88 | }, 89 | serialize: (value: string) => value, 90 | }); 91 | 92 | export const list = (allowedItems: string[], separator = ";") => 93 | param({ 94 | parse: (value: string) => { 95 | const items = value.split(separator); 96 | items.forEach((item) => { 97 | if (!allowedItems.includes(item)) { 98 | throw new Error( 99 | `"${item}" in ${value} is unknown. The allowed items are ${allowedItems.join( 100 | "," 101 | )}` 102 | ); 103 | } 104 | }); 105 | return items; 106 | }, 107 | serialize: (items: string[]) => items.join(separator), 108 | }); 109 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Context, 3 | CreateRoutes, 4 | ParseLocationFn, 5 | ParsePathFn, 6 | ParseQueryFn, 7 | RenderFn, 8 | RenderPathFn, 9 | RenderQueryFn, 10 | ReplaceFn, 11 | SafeParseLocationFn, 12 | SafeParsePathFn, 13 | SafeParseQueryFn, 14 | SafeParseResult, 15 | TemplateFn, 16 | WithContext, 17 | } from "./types"; 18 | 19 | export const createRoutes: CreateRoutes = (routeMap) => { 20 | const proxy = (ctx: Context): any => 21 | new Proxy( 22 | { 23 | ["~context"]: ctx, 24 | ["~routes"]: routeMap, 25 | }, 26 | { 27 | get: (target, maybeRouteName, receiver) => 28 | typeof maybeRouteName === "string" && maybeRouteName[0] !== "~" 29 | ? proxy(addRoute(maybeRouteName, ctx)) 30 | : Reflect.get(target, maybeRouteName, receiver), 31 | } 32 | ); 33 | 34 | return proxy({ 35 | isRelative: false, 36 | path: [], 37 | children: routeMap, 38 | nodes: [], 39 | relativeNodes: [], 40 | }); 41 | }; 42 | 43 | const addRoute = (routeName: string, ctx: Context): Context => { 44 | if (routeName === "_") { 45 | return { 46 | ...ctx, 47 | path: ctx.path.concat(routeName), 48 | nodes: ctx.nodes.concat(ctx.nodes), 49 | relativeNodes: [], 50 | isRelative: true, 51 | }; 52 | } 53 | const route = ctx.children?.[routeName]; 54 | if (!route) { 55 | throw Error( 56 | `unknown segment ${routeName} in ${ctx.path.concat(routeName)}` 57 | ); 58 | } 59 | return { 60 | ...ctx, 61 | path: ctx.path.concat(routeName), 62 | nodes: ctx.nodes.concat(route), 63 | relativeNodes: ctx.relativeNodes.concat(route), 64 | children: route.children, 65 | }; 66 | }; 67 | 68 | export const template: TemplateFn = ({ 69 | "~context": { isRelative, relativeNodes }, 70 | }) => { 71 | const prefix = isRelative ? "" : "/"; 72 | 73 | const serializedTemplate = relativeNodes 74 | .flatMap((route) => route.template ?? route.path ?? []) 75 | .map((segment) => 76 | typeof segment === "string" 77 | ? segment 78 | : segment.options?.template ?? 79 | `:${segment.name}${segment.kind === "optional" ? "?" : ""}` 80 | ) 81 | .join("/"); 82 | 83 | return prefix + serializedTemplate; 84 | }; 85 | 86 | export const renderPath: RenderPathFn = ( 87 | { "~context": { relativeNodes, isRelative } }, 88 | params: Record 89 | ) => { 90 | const serializedPath = relativeNodes 91 | .flatMap((route) => route.path ?? []) 92 | .flatMap((pathSegment) => 93 | typeof pathSegment === "string" 94 | ? pathSegment 95 | : params[pathSegment.name] !== undefined 96 | ? pathSegment.parser.serialize(params[pathSegment.name]) 97 | : [] 98 | ) 99 | .join("/"); 100 | 101 | return ( 102 | (isRelative || serializedPath.match(/^(http|https):\/\//) ? "" : "/") + 103 | serializedPath 104 | ); 105 | }; 106 | 107 | export const renderQuery: RenderQueryFn = ( 108 | { "~context": { nodes } }, 109 | params: Record 110 | ) => { 111 | const serializedQueryRecord: Record = {}; 112 | 113 | nodes 114 | .flatMap((route) => route.query ?? []) 115 | .forEach(({ name, parser }) => { 116 | if (params[name] !== undefined) { 117 | serializedQueryRecord[name] = parser.serialize(params[name]); 118 | } 119 | }); 120 | 121 | return new URLSearchParams(serializedQueryRecord).toString(); 122 | }; 123 | 124 | export const render: RenderFn = (route, params) => { 125 | const pathname = renderPath(route, params["path"]); 126 | const searchParams = renderQuery(route, params["query"]); 127 | const separator = searchParams ? `?` : ""; 128 | 129 | return pathname + separator + searchParams; 130 | }; 131 | 132 | export const parsePath: ParsePathFn = (route, paramsOrPath) => { 133 | const params = 134 | typeof paramsOrPath === "string" 135 | ? paramsFromLocationPath(route, paramsOrPath).pathParams 136 | : paramsOrPath; 137 | const parsedParams: Record = {}; 138 | 139 | route["~context"].relativeNodes 140 | .flatMap((route) => route.path ?? []) 141 | .forEach((segment) => { 142 | if (typeof segment === "string") { 143 | } else if (params[segment.name] !== undefined) { 144 | parsedParams[segment.name] = segment.parser.parse(params[segment.name]); 145 | } else if (segment.kind === "required") { 146 | throw Error( 147 | `parsePath: required path parameter "${ 148 | segment.name 149 | }" was not provided in "${template(route)}"` 150 | ); 151 | } 152 | }); 153 | 154 | return parsedParams as any; 155 | }; 156 | 157 | export const parseQuery: ParseQueryFn = (route, paramsOrQuery) => { 158 | const params = 159 | typeof paramsOrQuery === "string" 160 | ? paramsFromQuery(paramsOrQuery) 161 | : paramsOrQuery; 162 | 163 | const parsedQuery: Record = {}; 164 | 165 | route["~context"].nodes 166 | .flatMap((route) => route.query ?? []) 167 | .forEach((segment) => { 168 | const value = params[segment.name]; 169 | if (value != null) { 170 | parsedQuery[segment.name] = segment.parser.parse(value); 171 | } else if (segment.kind === "required") { 172 | throw Error( 173 | `parseQuery: required query parameter "${ 174 | segment.name 175 | }" was not provided in "${template(route)}"` 176 | ); 177 | } 178 | }); 179 | 180 | return parsedQuery as any; 181 | }; 182 | 183 | export const parseLocation: ParseLocationFn = (route, paramsOrLocation) => { 184 | const [pathParams, queryParams] = 185 | typeof paramsOrLocation === "string" 186 | ? paramsOrLocation.split("&") 187 | : [paramsOrLocation, paramsOrLocation]; 188 | 189 | return { 190 | path: parsePath(route, pathParams), 191 | query: parseQuery(route, queryParams), 192 | }; 193 | }; 194 | 195 | export const replace: ReplaceFn = ( 196 | route, 197 | location, 198 | params: Record 199 | ) => { 200 | const [locationPath, locationQuery] = location.split("?"); 201 | 202 | const { pathParams, remainingSegments } = paramsFromLocationPath( 203 | route, 204 | locationPath 205 | ); 206 | 207 | const { 208 | "~context": { isRelative, relativeNodes, nodes }, 209 | } = route; 210 | 211 | const pathname = relativeNodes 212 | .flatMap((route) => route.path ?? []) 213 | .flatMap((pathSegment) => { 214 | if (typeof pathSegment === "string") return pathSegment; 215 | 216 | const { name, kind, parser } = pathSegment; 217 | 218 | if (params["path"] && name in params["path"]) { 219 | if (typeof params["path"][name] !== "undefined") 220 | return parser.serialize(params["path"][name]); 221 | 222 | if (kind === "required") { 223 | throw Error( 224 | `replace: required path param ${name} can't be set to undefined` 225 | ); 226 | } 227 | return []; 228 | } 229 | 230 | return pathParams[name] ?? []; 231 | }) 232 | .concat(remainingSegments) 233 | .join("/"); 234 | 235 | const queryParams = paramsFromQuery(locationQuery); 236 | 237 | if (params["query"]) { 238 | nodes 239 | .flatMap((r) => r.query ?? []) 240 | .forEach(({ name, parser, kind }) => { 241 | if (typeof params["query"][name] !== "undefined") { 242 | queryParams[name] = parser.serialize(params["query"][name]); 243 | } else if (name in params["query"]) { 244 | if (kind === "required") { 245 | throw Error( 246 | `replace: required query param ${name} can't be set to undefined` 247 | ); 248 | } else { 249 | delete queryParams[name]; 250 | } 251 | } 252 | }); 253 | } 254 | 255 | const prefix = isRelative ? "" : "/"; 256 | const searchParams = new URLSearchParams(queryParams).toString(); 257 | const separator = searchParams ? `?` : ""; 258 | 259 | return prefix + pathname + separator + searchParams; 260 | }; 261 | 262 | export const paramsFromQuery = (query: string) => 263 | Object.fromEntries(new URLSearchParams(query)); 264 | 265 | export const paramsFromLocationPath = ( 266 | route: WithContext, 267 | locationPath: string = "" 268 | ) => { 269 | const remainingSegments = locationPath 270 | .slice(locationPath[0] === "/" ? 1 : 0) 271 | .split("/"); 272 | 273 | const pathParams: Record = {}; 274 | 275 | // keep track of recent optional params since they might contain path segments 276 | // if a path segment doesn't match the algorithm starts backtracking in this array 277 | const recentOptionalParams: string[] = []; 278 | 279 | route["~context"].relativeNodes 280 | .flatMap((r) => r.path ?? []) 281 | .forEach((segment) => { 282 | const locationPathSegment = remainingSegments.shift(); 283 | 284 | if (typeof segment === "string") { 285 | if (segment === locationPathSegment) { 286 | recentOptionalParams.length = 0; // irrelevant from here 287 | } else { 288 | // segment might have been swallowed by an optional param 289 | let recentParam: string | undefined; 290 | let foundMatch = false; 291 | while ((recentParam = recentOptionalParams.shift())) { 292 | if (pathParams[recentParam] === segment) { 293 | delete pathParams[recentParam]; 294 | // hold segment back for the next iteration 295 | locationPathSegment && 296 | remainingSegments.unshift(locationPathSegment); 297 | foundMatch = true; 298 | } 299 | } 300 | if (!foundMatch) { 301 | throw new Error( 302 | `"${locationPath}" doesn't match "${template( 303 | route 304 | )}", missing segment "${segment}"` 305 | ); 306 | } 307 | } 308 | } else { 309 | if (locationPathSegment != null) { 310 | pathParams[segment.name] = locationPathSegment; 311 | if (segment.kind === "optional") { 312 | recentOptionalParams.push(segment.name); 313 | } else { 314 | recentOptionalParams.length = 0; 315 | } 316 | } else if (segment.kind === "required") { 317 | throw new Error( 318 | `"${locationPath}" doesn't match "${template( 319 | route 320 | )}", missing parameter "${segment.name}"` 321 | ); 322 | } 323 | } 324 | }); 325 | 326 | return { 327 | pathParams, 328 | remainingSegments, 329 | }; 330 | }; 331 | 332 | export const safeCall = 333 | any>(fn: T) => 334 | (...params: any[]): SafeParseResult => { 335 | try { 336 | const result = fn(...params); 337 | return { 338 | success: true, 339 | data: result, 340 | }; 341 | } catch (err: unknown) { 342 | return { 343 | success: false, 344 | error: 345 | err instanceof Error 346 | ? err 347 | : new Error(err === "string" ? err : `unknown error: ${err}`), 348 | }; 349 | } 350 | }; 351 | 352 | export const safeParsePath: SafeParsePathFn = safeCall(parsePath); 353 | export const safeParseQuery: SafeParseQueryFn = safeCall(parseQuery); 354 | export const safeParseLocation: SafeParseLocationFn = safeCall(parseLocation); 355 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Compute } from "ts-toolbelt/out/Any/Compute"; 2 | import { OptionalDeep } from "ts-toolbelt/out/Object/Optional"; 3 | 4 | type Defined = Exclude; 5 | 6 | export type ParamKind = "optional" | "required"; 7 | 8 | export type AnyParam = Param; 9 | 10 | export interface ParamOptions { 11 | template?: string; 12 | } 13 | 14 | export type Param = { 15 | name: N; 16 | options?: ParamOptions; 17 | kind: K; 18 | parser: Parser; 19 | }; 20 | 21 | export interface Parser { 22 | parse: (value: string) => T; 23 | serialize: (value: T) => string; 24 | } 25 | 26 | export type RouteNode = { 27 | path?: (string | AnyParam)[]; 28 | template?: string; 29 | query?: AnyParam[]; 30 | children?: RouteNodeMap; 31 | meta?: any; 32 | }; 33 | export type RouteNodeMap = Record; 34 | 35 | /** 36 | * ComputeParamRecord< 37 | * { kind: "required"; name: "a"; parser: Parser } | 38 | * { kind: "optional"; name: "b"; parser: Parser } | 39 | * ... 40 | * > => { 41 | * [a]: string, 42 | * [b]?: number, 43 | * ... 44 | * } 45 | */ 46 | type ComputeParamRecord = Compute< 47 | { 48 | [K in Extract["name"]]: ReturnType< 49 | Extract["parser"]["parse"] 50 | >; 51 | } & { 52 | [K in Extract["name"]]?: ReturnType< 53 | Extract["parser"]["parse"] 54 | >; 55 | } 56 | >; 57 | 58 | export type InferPathParams = ComputeParamRecord< 59 | Extract< 60 | Extract< 61 | R["~context"]["relativeNodes"][number], 62 | Required> // excludes undefined paths -> allows path to be optional 63 | >["path"][number], 64 | AnyParam 65 | > 66 | >; 67 | 68 | export type InferQueryParams = ComputeParamRecord< 69 | Extract< 70 | Extract< 71 | R["~context"]["nodes"][number], 72 | Required> // excludes undefined queries -> allows query to be optional 73 | >["query"][number], 74 | AnyParam 75 | > 76 | >; 77 | 78 | export type InferParams = { 79 | path: InferPathParams; 80 | query: InferQueryParams; 81 | }; 82 | 83 | export type RoutesProps< 84 | Routes extends RouteNodeMap, 85 | Nodes extends RouteNode[] = [], 86 | RelativeNodes extends RouteNode[] = [] 87 | > = { 88 | "~context": Context; 89 | "~routes": Routes; 90 | _: RoutesProps; 91 | } & { 92 | [Segment in keyof Routes]: RoutesProps< 93 | Defined, 94 | [...Nodes, Routes[Segment]], // all nodes 95 | [...RelativeNodes, Routes[Segment]] // relative nodes 96 | >; 97 | }; 98 | 99 | export interface Context< 100 | Nodes extends RouteNode[] = RouteNode[], 101 | RelativeNodes extends RouteNode[] = RouteNode[] 102 | > { 103 | nodes: Nodes; 104 | path: string[]; 105 | relativeNodes: RelativeNodes; 106 | children?: RouteNodeMap; 107 | isRelative: boolean; 108 | } 109 | 110 | export type CreateRoutes = ( 111 | routes: Routes 112 | ) => RoutesProps; 113 | 114 | export interface WithContext { 115 | "~context": Context; 116 | } 117 | 118 | export type TemplateFn = (route: R) => string; 119 | 120 | export type RenderPathFn = ( 121 | route: R, 122 | params: InferPathParams 123 | ) => string; 124 | 125 | export type RenderQueryFn = ( 126 | route: R, 127 | params: InferQueryParams 128 | ) => string; 129 | 130 | export type RenderFn = ( 131 | route: R, 132 | params: InferParams 133 | ) => string; 134 | 135 | export type ParsePathFn = ( 136 | route: R, 137 | paramsOrPath: Record | string 138 | ) => InferPathParams; 139 | 140 | export type ParseQueryFn = ( 141 | route: R, 142 | paramsOrQuery: Record | string 143 | ) => InferQueryParams; 144 | 145 | export type ParseLocationFn = ( 146 | route: R, 147 | paramsOrLocation: Record | string 148 | ) => InferParams; 149 | 150 | export type SafeParsePathFn = ( 151 | route: R, 152 | paramsOrPath: Record | string 153 | ) => SafeParseResult>; 154 | 155 | export type SafeParseQueryFn = ( 156 | route: R, 157 | paramsOrQuery: Record | string 158 | ) => SafeParseResult>; 159 | 160 | export type SafeParseLocationFn = ( 161 | route: R, 162 | paramsOrLocation: Record | string 163 | ) => SafeParseResult>; 164 | 165 | export type ReplaceFn = ( 166 | route: R, 167 | location: string, 168 | params: OptionalDeep> 169 | ) => string; 170 | 171 | export type SafeParseResult = 172 | | { success: true; data: T } 173 | | { success: false; error: Error }; 174 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import test from "tape"; 2 | import { 3 | bool, 4 | createRoutes, 5 | date, 6 | float, 7 | int, 8 | isoDate, 9 | list, 10 | oneOf, 11 | parsePath, 12 | parseQuery, 13 | render, 14 | renderPath, 15 | renderQuery, 16 | replace, 17 | safeParsePath, 18 | safeParseQuery, 19 | str, 20 | template, 21 | } from "../src"; 22 | 23 | test("template", (t) => { 24 | const routes = createRoutes({ 25 | home: {}, 26 | blog: { 27 | path: ["blog", str("lang")], 28 | children: { 29 | $wildcard: { template: "**" }, 30 | category: { 31 | path: ["category", str("cid")], 32 | children: { 33 | $wildcard: { template: "**" }, 34 | date: { 35 | path: [isoDate("date")], 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }); 42 | 43 | t.equal(template(routes.home), "/"); 44 | t.equal(template(routes.blog), "/blog/:lang"); 45 | t.equal(template(routes.blog.$wildcard), "/blog/:lang/**"); 46 | t.equal( 47 | template(routes.blog.category.date), 48 | "/blog/:lang/category/:cid/:date" 49 | ); 50 | t.equal( 51 | template(routes.blog.category.$wildcard), 52 | "/blog/:lang/category/:cid/**" 53 | ); 54 | t.equal(template(routes.blog._.category.$wildcard), "category/:cid/**"); 55 | t.equal(template(routes.blog._.category.date), "category/:cid/:date"); 56 | t.equal(template(routes.blog.category._.date), ":date"); 57 | 58 | t.end(); 59 | }); 60 | 61 | test("custom templates ", (t) => { 62 | const routes = createRoutes({ 63 | customTemplate: { 64 | template: "segment/:param/segment", 65 | }, 66 | customParamTemplate: { 67 | path: ["segment", str("title", { template: ":title.(mp4|mov)" })], 68 | }, 69 | }); 70 | 71 | t.equal(template(routes.customTemplate), "/segment/:param/segment"); 72 | t.equal(template(routes.customParamTemplate), "/segment/:title.(mp4|mov)"); 73 | 74 | t.end(); 75 | }); 76 | 77 | test("renderPath", (t) => { 78 | const routes = createRoutes({ 79 | home: {}, 80 | blog: { 81 | path: ["blog", str("lang")], 82 | children: { 83 | category: { 84 | path: ["category", str("cid"), int.optional("extra")], 85 | query: [str.optional("search")], 86 | children: { 87 | date: { 88 | path: [date("date")], 89 | query: [int("page")], 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | }); 96 | 97 | t.equal(renderPath(routes, {}), "/", "renders home route on empty context"); 98 | t.equal(renderPath(routes.home, {}), "/"); 99 | t.equal(renderPath(routes.blog, { lang: "en" }), "/blog/en"); 100 | t.equal( 101 | renderPath(routes.blog.category, { lang: "en", cid: "movies" }), 102 | "/blog/en/category/movies", 103 | "with one optional path param omitted" 104 | ); 105 | t.end(); 106 | }); 107 | 108 | test("renderQuery ", (t) => { 109 | const routes = createRoutes({ 110 | blog: { 111 | path: ["blog", str("lang")], 112 | children: { 113 | category: { 114 | path: ["category", str("cid")], 115 | query: [str.optional("search")], 116 | children: { 117 | date: { 118 | path: [date("date")], 119 | query: [int("page")], 120 | }, 121 | }, 122 | }, 123 | }, 124 | }, 125 | }); 126 | 127 | t.equal(renderQuery(routes, {}), ""); 128 | t.equal(renderQuery(routes.blog, {}), ""); 129 | t.equal(renderQuery(routes.blog.category, {}), ""); 130 | t.equal( 131 | renderQuery(routes.blog.category, { search: "robocop" }), 132 | "search=robocop" 133 | ); 134 | t.equal(renderQuery(routes.blog.category.date, { page: 1 }), "page=1"); 135 | t.equal( 136 | renderQuery(routes.blog.category.date, { search: "robocop", page: 1 }), 137 | "search=robocop&page=1" 138 | ); 139 | t.equal(renderQuery(routes.blog.category._.date, { page: 2 }), "page=2"); 140 | t.equal( 141 | renderQuery(routes.blog.category._.date, { search: "robocop", page: 2 }), 142 | "search=robocop&page=2", 143 | "should include complete query in relative paths" 144 | ); 145 | t.end(); 146 | }); 147 | 148 | test("render", (t) => { 149 | const routes = createRoutes({ 150 | home: {}, 151 | blog: { 152 | path: ["blog", str("lang")], 153 | children: { 154 | category: { 155 | path: ["category", str("cid")], 156 | query: [str.optional("search")], 157 | children: { 158 | date: { 159 | path: [date("date")], 160 | query: [int("page")], 161 | }, 162 | }, 163 | }, 164 | }, 165 | }, 166 | }); 167 | 168 | t.equal(render(routes, { path: {}, query: {} }), "/"); 169 | t.equal(render(routes.home, { path: {}, query: {} }), "/"); 170 | t.equal(render(routes.blog, { path: { lang: "en" }, query: {} }), "/blog/en"); 171 | t.equal( 172 | render(routes.blog.category, { 173 | path: { lang: "en", cid: "movies" }, 174 | query: {}, 175 | }), 176 | "/blog/en/category/movies" 177 | ); 178 | t.equal( 179 | render(routes.blog.category, { 180 | path: { lang: "en", cid: "movies" }, 181 | query: { search: "robocop" }, 182 | }), 183 | "/blog/en/category/movies?search=robocop" 184 | ); 185 | t.equal( 186 | render(routes.blog.category.date, { 187 | path: { lang: "en", cid: "movies", date: new Date(1703798091000) }, 188 | query: { search: "robocop", page: 42 }, 189 | }), 190 | "/blog/en/category/movies/2023-12-28?search=robocop&page=42" 191 | ); 192 | t.equal( 193 | render(routes.blog.category.date, { 194 | path: { lang: "en", cid: "movies", date: new Date(1703798091000) }, 195 | query: { search: "robocop", page: 0 }, 196 | }), 197 | "/blog/en/category/movies/2023-12-28?search=robocop&page=0", 198 | "falsy parameters should be rendered" 199 | ); 200 | t.equal( 201 | render(routes.blog._.category.date, { 202 | path: { cid: "movies", date: new Date(1703798091000) }, 203 | query: { search: "robocop", page: 42 }, 204 | }), 205 | "category/movies/2023-12-28?search=robocop&page=42" 206 | ); 207 | t.equal( 208 | render(routes.blog._.category.date, { 209 | path: { cid: "movies", date: new Date(1703798091000) }, 210 | query: { page: 42 }, 211 | }), 212 | "category/movies/2023-12-28?page=42" 213 | ); 214 | 215 | t.end(); 216 | }); 217 | 218 | test("parsePath", (t) => { 219 | const routes = createRoutes({ 220 | home: {}, 221 | blog: { 222 | path: ["blog", bool("lang")], 223 | children: { 224 | category: { 225 | path: ["category", int.optional("cid")], 226 | children: { 227 | date: { 228 | path: ["date", date.optional("date")], 229 | }, 230 | }, 231 | }, 232 | }, 233 | }, 234 | }); 235 | 236 | t.deepEqual( 237 | parsePath(routes.blog.category.date, { 238 | lang: "true", 239 | cid: "42", 240 | date: "2023-12-28", 241 | }), 242 | { 243 | lang: true, 244 | cid: 42, 245 | date: new Date("2023-12-28T00:00:00.000Z"), 246 | } 247 | ); 248 | 249 | t.deepEqual( 250 | parsePath( 251 | routes.blog.category.date, 252 | "blog/false/category/24/date/2024-11-29" 253 | ), 254 | { 255 | lang: false, 256 | cid: 24, 257 | date: new Date("2024-11-29T00:00:00.000Z"), 258 | } 259 | ); 260 | 261 | t.deepEqual( 262 | parsePath(routes.blog.category.date, { 263 | lang: "true", 264 | cid: "0", // potentially falsy 265 | date: "2023-12-28", 266 | }), 267 | { 268 | lang: true, 269 | cid: 0, 270 | date: new Date("2023-12-28T00:00:00.000Z"), 271 | } 272 | ); 273 | 274 | t.deepEqual( 275 | parsePath( 276 | routes.blog.category.date, 277 | "blog/true/category/0/date/2024-11-29" 278 | ), 279 | { 280 | lang: true, 281 | cid: 0, 282 | date: new Date("2024-11-29T00:00:00.000Z"), 283 | } 284 | ); 285 | 286 | t.deepEqual( 287 | parsePath(routes.blog._.category.date, { 288 | cid: "42", 289 | }), 290 | { cid: 42 }, 291 | "relative path with optional params" 292 | ); 293 | 294 | t.deepEqual( 295 | parsePath(routes.blog._.category.date, "category/42/date"), 296 | { 297 | cid: 42, 298 | }, 299 | "relative path with omitted optional params in string path" 300 | ); 301 | 302 | t.deepEqual( 303 | parsePath(routes.blog._.category.date, "category/244/date/2024-10-29"), 304 | { 305 | cid: 244, 306 | date: new Date("2024-10-29T00:00:00.000Z"), 307 | }, 308 | "relative path with all optional params in string path" 309 | ); 310 | 311 | t.throws( 312 | () => 313 | parsePath(routes.blog.category._.date, "category/244/date/2024-10-29"), 314 | "string path mismatch" 315 | ); 316 | 317 | t.deepEqual( 318 | safeParsePath( 319 | routes.blog.category.date, 320 | "blog/true/category/0/date/2024-11-29" 321 | ), 322 | { 323 | success: true, 324 | data: { 325 | lang: true, 326 | cid: 0, 327 | date: new Date("2024-11-29T00:00:00.000Z"), 328 | }, 329 | }, 330 | "safeCall success" 331 | ); 332 | t.deepEqual( 333 | safeParsePath(routes.blog.category._.date, "category/244/date/2024-10-29"), 334 | { 335 | success: false, 336 | error: Error( 337 | `"category/244/date/2024-10-29" doesn't match "date/:date?", missing segment "date"` 338 | ), 339 | }, 340 | "safeCall error" 341 | ); 342 | 343 | t.end(); 344 | }); 345 | 346 | test("parseQuery", (t) => { 347 | const routes = createRoutes({ 348 | home: {}, 349 | blog: { 350 | path: ["blog"], 351 | query: [str("lang")], 352 | children: { 353 | category: { 354 | path: ["movies"], 355 | query: [str("category"), bool("shortmovie")], 356 | children: { 357 | date: { 358 | path: ["2023"], 359 | query: [oneOf("jan", "feb", "mar", "apr", "...")("month")], 360 | }, 361 | }, 362 | }, 363 | }, 364 | }, 365 | }); 366 | 367 | t.deepEqual( 368 | parseQuery(routes.blog.category.date, { 369 | lang: "en", 370 | category: "drama", 371 | shortmovie: "true", 372 | month: "feb", 373 | }), 374 | { 375 | lang: "en", 376 | category: "drama", 377 | shortmovie: true, 378 | month: "feb", 379 | } 380 | ); 381 | 382 | t.deepEqual( 383 | parseQuery( 384 | routes.blog.category.date, 385 | "lang=en&category=drama&shortmovie=true&month=feb" 386 | ), 387 | { 388 | lang: "en", 389 | category: "drama", 390 | shortmovie: true, 391 | month: "feb", 392 | } 393 | ); 394 | 395 | t.deepEqual( 396 | parseQuery(routes.blog._.category.date, { 397 | lang: "en", // includes all params despite relative query 398 | category: "drama", 399 | shortmovie: "true", 400 | month: "feb", 401 | }), 402 | { 403 | lang: "en", 404 | category: "drama", 405 | shortmovie: true, 406 | month: "feb", 407 | } 408 | ); 409 | 410 | t.deepEqual( 411 | parseQuery( 412 | routes.blog._.category.date, 413 | "lang=en&category=drama&shortmovie=true&month=feb" 414 | ), 415 | { 416 | lang: "en", 417 | category: "drama", 418 | shortmovie: true, 419 | month: "feb", 420 | } 421 | ); 422 | t.throws(() => parseQuery(routes.blog.category._.date, {})); 423 | t.throws(() => parseQuery(routes.blog.category._.date, { month: "jun" })); 424 | t.throws(() => parseQuery(routes.blog._.category.date, "lang=en&category")); 425 | t.deepEqual( 426 | safeParseQuery( 427 | routes.blog.category.date, 428 | "lang=en&category=drama&shortmovie=true&month=feb" 429 | ), 430 | { 431 | success: true, 432 | data: { 433 | lang: "en", 434 | category: "drama", 435 | shortmovie: true, 436 | month: "feb", 437 | }, 438 | } 439 | ); 440 | t.deepEqual( 441 | safeParseQuery(routes.blog._.category.date, { 442 | lang: "en", // includes all params despite relative query 443 | category: "drama", 444 | shortmovie: "true", 445 | month: "feb", 446 | }), 447 | { 448 | success: true, 449 | data: { 450 | lang: "en", 451 | category: "drama", 452 | shortmovie: true, 453 | month: "feb", 454 | }, 455 | } 456 | ); 457 | t.deepEqual( 458 | safeParseQuery(routes.blog.category.date, ""), 459 | { 460 | success: false, 461 | error: Error( 462 | 'parseQuery: required query parameter "lang" was not provided in "/blog/movies/2023"' 463 | ), 464 | }, 465 | "safeCall failed" 466 | ); 467 | t.end(); 468 | }); 469 | 470 | test("replace", (t) => { 471 | const routes = createRoutes({ 472 | home: {}, 473 | blog: { 474 | path: ["blog", str("lang")], 475 | children: { 476 | category: { 477 | path: ["category", str.optional("cid")], 478 | query: [str.optional("search")], 479 | children: { 480 | date: { 481 | path: ["date", date("date")], 482 | query: [int.optional("page")], 483 | }, 484 | }, 485 | }, 486 | }, 487 | }, 488 | }); 489 | 490 | t.equal( 491 | replace( 492 | routes.blog.category, 493 | "/blog/en/category/movies/date/2012-12-28?search=batman&page=1", 494 | { path: { cid: "art" } } 495 | ), 496 | "/blog/en/category/art/date/2012-12-28?search=batman&page=1", 497 | "should replace path params in absolute path" 498 | ); 499 | 500 | t.equal( 501 | replace( 502 | routes.blog._.category.date, 503 | "category/movies/date/2012-12-28?search=batman&page=1", 504 | { path: { cid: "art" } } 505 | ), 506 | "category/art/date/2012-12-28?search=batman&page=1", 507 | "should replace params in relative path" 508 | ); 509 | 510 | t.equal( 511 | replace(routes.blog, "/blog/en?additionalParam=value", { 512 | path: { lang: "es" }, 513 | }), 514 | "/blog/es?additionalParam=value", 515 | "should keep additional params" 516 | ); 517 | 518 | t.equal( 519 | replace( 520 | routes.blog._.category, 521 | "category/movies/date/2012-12-28?search=batman&page=1", 522 | { path: { cid: "art" }, query: { search: undefined } } 523 | ), 524 | "category/art/date/2012-12-28?page=1", 525 | "should remove param" 526 | ); 527 | 528 | t.throws( 529 | () => 530 | replace(routes.blog._.category.date, "category/movies/date/2012-12-28", { 531 | path: { date: undefined }, 532 | }), 533 | "throws when deleting a required path parameter" 534 | ); 535 | 536 | t.comment("todo: should replace query params in absolute path"); 537 | t.comment("todo: should replace query params in relative path"); 538 | t.comment("todo: should replace path & query params in absolute path"); 539 | t.comment("todo: should replace path & query params in relative path"); 540 | t.end(); 541 | }); 542 | 543 | test("params", (t) => { 544 | const i = int("").parser; 545 | t.equal(i.parse("5.4"), 5, "int should parse"); 546 | t.equal(i.serialize(5), "5", "int should serialize"); 547 | t.throws(() => i.parse("abc"), "int should validate"); 548 | 549 | const f2 = float(2)("").parser; 550 | t.equal(f2.parse("5.43"), 5.43, "float should parse"); 551 | t.equal(f2.serialize(5.4), "5.40", "float should serialize"); 552 | t.throws(() => f2.parse("abc"), "float should validate"); 553 | 554 | const d = isoDate("").parser; 555 | t.deepEqual( 556 | d.parse("2024-01-29T17:27:22.302Z"), 557 | new Date("2024-01-29T17:27:22.302Z"), 558 | "isoDate should parse" 559 | ); 560 | t.equal( 561 | d.serialize(new Date("2024-01-29T17:27:22.302Z")), 562 | "2024-01-29T17:27:22.302Z", 563 | "isoDate should serialize" 564 | ); 565 | t.throws(() => d.parse("abc"), "isoDate should validate"); 566 | 567 | const b = bool("").parser; 568 | t.deepEqual(b.parse("true"), true, "bool should parse"); 569 | t.equal(b.serialize(false), "false", "bool should serialize"); 570 | 571 | const o = oneOf("a", "b", "c")("").parser; 572 | t.equal(o.parse("b"), "b", "oneOf should parse"); 573 | t.equal(o.serialize("c"), "c", "oneOf should serialize"); 574 | t.throws(() => o.parse("d"), "oneOf should validate"); 575 | 576 | const l = list(["a", "b", "c"], "|")("").parser; 577 | t.deepEqual(l.parse("b|c"), ["b", "c"], "list should parse"); 578 | t.equal(l.serialize(["a", "c"]), "a|c", "list should serialize"); 579 | t.throws(() => l.parse("d|e|f"), "list should validate"); 580 | 581 | t.end(); 582 | }); 583 | 584 | test("route composition", (t) => { 585 | const usersRoutes = createRoutes({ 586 | list: { 587 | path: ["list"], 588 | }, 589 | detail: { 590 | path: ["detail", int("uid")], 591 | }, 592 | }); 593 | 594 | const cartRoutes = createRoutes({ 595 | detail: { 596 | path: ["detail"], 597 | }, 598 | }); 599 | 600 | const globalRoutes = createRoutes({ 601 | home: { 602 | path: ["home"], 603 | }, 604 | }); 605 | 606 | const routes = createRoutes({ 607 | ...globalRoutes["~routes"], 608 | user: { 609 | path: ["user"], 610 | children: usersRoutes["~routes"], 611 | }, 612 | cart: { 613 | path: ["cart"], 614 | children: cartRoutes["~routes"], 615 | }, 616 | }); 617 | 618 | t.equals(renderPath(routes.home, {}), "/home"); 619 | t.equals(renderPath(routes.user.list, {}), "/user/list"); 620 | t.equals(renderPath(routes.user.detail, { uid: 123 }), "/user/detail/123"); 621 | t.equals(renderPath(routes.cart.detail, {}), "/cart/detail"); 622 | 623 | t.end(); 624 | }); 625 | 626 | test("render baseUrl", (t) => { 627 | const internalRoutes = createRoutes({ 628 | list: { 629 | path: ["list"], 630 | }, 631 | detail: { 632 | path: ["detail", int("uid")], 633 | }, 634 | }); 635 | 636 | const externalRoutes = createRoutes({ 637 | baseUrl: { 638 | path: ["https://typesafe.routes"], 639 | children: internalRoutes["~routes"], 640 | }, 641 | }); 642 | 643 | t.equals( 644 | renderPath(externalRoutes.baseUrl.list, {}), 645 | "https://typesafe.routes/list" 646 | ); 647 | 648 | t.equals( 649 | renderPath(externalRoutes.baseUrl.detail, { uid: 123 }), 650 | "https://typesafe.routes/detail/123" 651 | ); 652 | 653 | t.end(); 654 | }); 655 | 656 | // test("angular routes", (t) => { 657 | // const createRoute: CreateAngularRoutes<{ component: string }> = 658 | // createAngularRoutes; 659 | 660 | // const r = createRoute({ 661 | // a: { 662 | // path: ["string", int("parm")], 663 | // meta: { component: "A" }, 664 | // children: { 665 | // b: { 666 | // meta: { component: "B" }, 667 | // }, 668 | // }, 669 | // }, 670 | // c: { 671 | // path: ["string", int("lala")], 672 | // }, 673 | // }); 674 | 675 | // t.deepEquals(r.$routes.a.meta, { component: "A" }); 676 | // t.deepEquals(r.$provider, [ 677 | // { 678 | // path: "string/:parm", 679 | // component: "A", 680 | // children: [ 681 | // { 682 | // path: "string/:parm", 683 | // children: undefined, 684 | // component: "B", 685 | // }, 686 | // ], 687 | // }, 688 | // { 689 | // path: "string/:lala", 690 | // children: undefined, 691 | // }, 692 | // ]); 693 | // t.end(); 694 | // }); 695 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | bool, 3 | createRoutes, 4 | InferPathParams, 5 | InferQueryParams, 6 | int, 7 | str, 8 | } from "../src"; 9 | 10 | type AssertEqual = T extends U 11 | ? U extends T 12 | ? true 13 | : "Types are not equal" 14 | : "Types are not equal"; 15 | 16 | const assertEqual = (value: AssertEqual): void => {}; 17 | 18 | const expectType = (_: T) => {}; 19 | 20 | const r = createRoutes({ 21 | home: {}, 22 | language: { 23 | path: [str("lang")], 24 | children: { 25 | users: { 26 | path: ["users"], 27 | query: [int("page")], 28 | children: { 29 | show: { 30 | path: ["show", int.optional("userId")], 31 | query: [bool.optional("filter")], 32 | }, 33 | }, 34 | }, 35 | }, 36 | }, 37 | }); 38 | 39 | // 40 | // path segments 41 | // 42 | assertEqual(true); 43 | assertEqual(true); 44 | assertEqual(true); 45 | assertEqual(true); 46 | assertEqual(true); 47 | assertEqual(true); 48 | assertEqual(true); 49 | assertEqual(true); 50 | assertEqual(true); 51 | assertEqual(true); 52 | // @ts-expect-error 53 | assertEqual(true); 54 | 55 | // 56 | // PathParamsRecord 57 | // 58 | assertEqual, {}>(true); 59 | assertEqual, { lang: string }>(true); 60 | assertEqual< 61 | InferPathParams, 62 | { lang: string; userId?: number } 63 | >(true); 64 | assertEqual< 65 | InferPathParams, 66 | { userId?: number } 67 | >(true); 68 | 69 | // 70 | // QueryParamsRecord 71 | // 72 | // undefined queries in path 73 | assertEqual, {}>(true); 74 | // defined queries in path 75 | assertEqual< 76 | InferQueryParams, 77 | { page: number; filter?: boolean } 78 | >(true); 79 | // relative path 80 | assertEqual< 81 | InferQueryParams, 82 | { page: number; filter?: boolean } 83 | >(true); 84 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "sourceMap": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "jsx": "react", 8 | "skipLibCheck": true, 9 | "downlevelIteration": true, 10 | "target": "ES6", 11 | "module": "node16", 12 | "moduleResolution": "node16", 13 | }, 14 | "files": [ 15 | "src/index.ts", 16 | "src/angular-router/index.ts", 17 | ] 18 | } --------------------------------------------------------------------------------