├── .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 | 
2 | 
3 | 
4 | [](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 |
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 | }
--------------------------------------------------------------------------------