Skip to content

Commit ce9bf9d

Browse files
committed
feat: typesafeBrowserRouter
1 parent d6b3e0c commit ce9bf9d

File tree

6 files changed

+167
-63
lines changed

6 files changed

+167
-63
lines changed

.changeset/flat-roses-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-router-typesafe': minor
3+
---
4+
5+
Added new utility `typesafeBrowserRouter`

README.md

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ const Component = () => {
3939
> **Warning**
4040
> Do not annotate the type of the loader/action function. It will break the type-safety. Instead rely on either the `satisfies` keyword from Typescript 4.9 onwards, or the `makeLoader` / `makeAction` utilities proveded by this library.
4141
42-
### Utilities
42+
## Utilities
43+
44+
### makeLoader / makeAction
4345

4446
The `makeLoader` and `makeAction` utils replace the need for the `satisfies` keyword without adding any runtime overhead.
4547

@@ -51,21 +53,61 @@ const loader = makeLoader(() => ({ message: 'Hello World' }));
5153
const action = makeAction(() => ({ ok: true }));
5254
```
5355

56+
### typesafeBrowserRouter ❇️ NEW
57+
58+
The `typesafeBrowserRouter` is a wrapper around `createBrowserRoute` that returns a `href` function in addition to the routes.
59+
60+
It’s easy to incrementally adopt, and you can use `href` anywhere, not just in `<Link>` components.
61+
62+
Set up your routes like this:
63+
64+
```diff
65+
- import { createBrowserRouter } from "react-router-dom";
66+
+ import { typesafeBrowserRouter } from "react-router-typesafe";
67+
68+
- export const router = createBrowserRouter([
69+
+ export const { router, href } = typesafeBrowserRouter([
70+
{ path: "/", Component: HomePage },
71+
{ path: "/projects/:projectId", Component: ProjectPage },
72+
]);
73+
```
74+
75+
- ✅ No need to change your existing `<Link>` components.
76+
- ✅ URL params are inferred and type-checked.
77+
- ✅ Supports query params and URL hash
78+
- ✅ Refactor-friendly: **Rename Symbol** on the route path and it’ll be updated everywhere.
79+
80+
Then use `href` to generate URLs:
81+
82+
```tsx
83+
import { Link } from 'react-router-dom';
84+
import { href } from './router';
85+
86+
const ProjectCard = (props: { id: string }) => {
87+
return (
88+
<Link to={href({ path: '/projects/:projectId', query: { projectId: props.id } })}>
89+
<p>Project {projectId}</p>
90+
</Link>
91+
);
92+
};
93+
```
94+
5495
## Contributing
5596

5697
Feel free to improve the code and submit a pull request. If you're not sure about something, create an issue first to discuss it.
5798

5899
## Functions
59100

60-
| Status | Utility | Before | After |
61-
| ------ | -------------------- | ---------- | ------------------------------------------------------------ |
62-
|| `defer` | `Response` | Generic matching the first argument |
63-
| | `json` | `Response` | Serialized data passed in |
64-
|| `useLoaderData` | `unknown` | Generic function with the type of the loader function passed |
65-
|| `useActionData` | `unknown` | Generic function with the type of the action function passed |
66-
|| `useRouteLoaderData` | `unknown` | Generic function with the type of the loader function passed |
67-
| NEW | `makeLoader` | | Wrapper around `satisfies` for ergonomics |
68-
| NEW | `makeAction` | | Wrapper around `satisfies` for ergonomics |
101+
| Status | Utility | Before | After |
102+
| ------ | ----------------------- | ---------- | ------------------------------------------------------------ |
103+
|| `defer` | `Response` | Generic matching the first argument |
104+
| | `json` | `Response` | Serialized data passed in |
105+
|| `useLoaderData` | `unknown` | Generic function with the type of the loader function passed |
106+
|| `useActionData` | `unknown` | Generic function with the type of the action function passed |
107+
|| `useRouteLoaderData` | `unknown` | Generic function with the type of the loader function passed |
108+
| NEW | `makeLoader` | | Wrapper around `satisfies` for ergonomics |
109+
| NEW | `makeAction` | | Wrapper around `satisfies` for ergonomics |
110+
| NEW | `typesafeBrowserRouter` | | Extension of `createBrowserRouter` |
69111

70112
## Patched components
71113

bun.lockb

369 Bytes
Binary file not shown.

package.json

Lines changed: 54 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,56 @@
11
{
2-
"name": "react-router-typesafe",
3-
"version": "1.3.4",
4-
"repository": {
5-
"type": "git",
6-
"url": "https://github.com/stargaze-co/react-router-typesafe.git"
7-
},
8-
"description": "type safe patches of react-router-dom",
9-
"main": "./dist/index.js",
10-
"module": "./dist/index.mjs",
11-
"types": "./dist/index.d.ts",
12-
"exports": {
13-
".": {
14-
"require": "./dist/index.js",
15-
"import": "./dist/index.mjs",
16-
"types": "./dist/index.d.ts"
17-
}
18-
},
19-
"files": [
20-
"dist"
21-
],
22-
"scripts": {
23-
"build": "tsup",
24-
"release": "bun run build && changeset publish"
25-
},
26-
"keywords": [
27-
"react",
28-
"react-router",
29-
"react-router-dom",
30-
"remix",
31-
"remix-router"
32-
],
33-
"author": "fredericoo",
34-
"license": "ISC",
35-
"peerDependencies": {
36-
"react": ">= 17",
37-
"react-router-dom": ">= 6.4.0",
38-
"typescript": ">= 4.9"
39-
},
40-
"devDependencies": {
41-
"@changesets/cli": "^2.26.2",
42-
"@happy-dom/global-registrator": "^11.0.6",
43-
"@testing-library/react": "^14.0.0",
44-
"bun-types": "^1.0.1",
45-
"eslint": "^8.45.0",
46-
"expect-type": "^0.16.0",
47-
"happy-dom": "^11.0.6",
48-
"prettier": "^3.0.0",
49-
"react": "^18.2.0",
50-
"react-router-dom": "^6.16.0",
51-
"tsup": "^7.1.0",
52-
"typescript": "^5.1.6"
53-
},
54-
"dependencies": {}
2+
"name": "react-router-typesafe",
3+
"version": "1.3.4",
4+
"repository": {
5+
"type": "git",
6+
"url": "https://github.com/stargaze-co/react-router-typesafe.git"
7+
},
8+
"description": "type safe patches of react-router-dom",
9+
"main": "./dist/index.js",
10+
"module": "./dist/index.mjs",
11+
"types": "./dist/index.d.ts",
12+
"exports": {
13+
".": {
14+
"require": "./dist/index.js",
15+
"import": "./dist/index.mjs",
16+
"types": "./dist/index.d.ts"
17+
}
18+
},
19+
"files": [
20+
"dist"
21+
],
22+
"scripts": {
23+
"build": "tsup",
24+
"release": "bun run build && changeset publish"
25+
},
26+
"keywords": [
27+
"react",
28+
"react-router",
29+
"react-router-dom",
30+
"remix",
31+
"remix-router"
32+
],
33+
"author": "fredericoo",
34+
"license": "ISC",
35+
"peerDependencies": {
36+
"react": ">= 17",
37+
"react-router-dom": ">= 6.4.0",
38+
"typescript": ">= 4.9"
39+
},
40+
"devDependencies": {
41+
"@changesets/cli": "^2.26.2",
42+
"@happy-dom/global-registrator": "^11.0.6",
43+
"@testing-library/react": "^14.0.0",
44+
"bun-types": "^1.0.1",
45+
"eslint": "^8.45.0",
46+
"expect-type": "^0.16.0",
47+
"happy-dom": "^11.0.6",
48+
"prettier": "^3.0.0",
49+
"react": "^18.2.0",
50+
"react-router-dom": "^6.16.0",
51+
"ts-toolbelt": "^9.6.0",
52+
"tsup": "^7.1.0",
53+
"typescript": "^5.1.6"
54+
},
55+
"dependencies": {}
5556
}

src/browser-router.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { RouteObject, createBrowserRouter } from 'react-router-dom';
2+
import { F } from 'ts-toolbelt';
3+
4+
type ExtractParam<Path, NextPart> = Path extends `:${infer Param}` ? Record<Param, string> & NextPart : NextPart;
5+
6+
type ExtractParams<Path> = Path extends `${infer Segment}/${infer Rest}`
7+
? ExtractParam<Segment, ExtractParams<Rest>>
8+
: ExtractParam<Path, {}>;
9+
10+
type TypesafeRouteParams<Routes extends RouteObject[]> = {
11+
[K in keyof Routes]: Routes[K] extends { path: string; children: RouteObject[] }
12+
?
13+
| { path: Routes[K]['path']; pathParams: ExtractParams<Routes[K]['path']> }
14+
| TypesafeRouteParams<Routes[K]['children']>
15+
: Routes[K] extends { path: string }
16+
? { path: Routes[K]['path']; pathParams: ExtractParams<Routes[K]['path']> }
17+
: never;
18+
}[number];
19+
20+
type TypesafeSearchParams = Record<string, string> | URLSearchParams;
21+
export type RouteExtraParams = { hash?: string; searchParams?: TypesafeSearchParams };
22+
23+
function invariant(condition: any, message?: string): asserts condition {
24+
if (condition) return;
25+
throw new Error(message);
26+
}
27+
28+
const joinValidWith =
29+
(separator: string) =>
30+
(...valid: any[]) =>
31+
valid.filter(Boolean).join(separator);
32+
33+
function assertPathAndParams(params: unknown): asserts params is { path: string; pathParams: Record<string, string> } {
34+
invariant(params && typeof params === 'object' && 'path' in params && 'pathParams' in params);
35+
invariant(typeof params.path === 'string');
36+
invariant(typeof params.pathParams === 'object' && params.pathParams !== null);
37+
}
38+
39+
export const typesafeBrowserRouter = <R extends RouteObject>(routes: F.Narrow<R[]>) => {
40+
return {
41+
router: createBrowserRouter(routes as RouteObject[]),
42+
href: (route: TypesafeRouteParams<R[]> & RouteExtraParams) => {
43+
assertPathAndParams(route);
44+
let path = route.path;
45+
// applies all params to the path
46+
for (const param in route.pathParams) {
47+
path = route.path.replace(`:${param}`, route.pathParams[param] as string);
48+
}
49+
const searchParams = new URLSearchParams(route.searchParams);
50+
const hash = route.hash?.replace(/^#/, '');
51+
52+
return joinValidWith('#')(joinValidWith('?')(path, searchParams.toString()), hash);
53+
},
54+
};
55+
};

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { type LoaderData, useLoaderData, useRouteLoaderData } from './loader';
33
export { type ActionData, useActionData } from './action';
44
export { makeLoader, makeAction } from './utils';
55
export * from './components';
6+
export * from './browser-router';
67

78
/** Re-exports for commodity */
89
export { type LoaderFunction, type ActionFunction, redirect } from 'react-router-dom';

0 commit comments

Comments
 (0)