├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.module.css ├── App.test.tsx ├── App.tsx ├── components │ ├── TypedLink.tsx │ ├── TypedRedirect.tsx │ └── screens │ │ ├── Calendar.tsx │ │ ├── Home.tsx │ │ ├── LogIn.tsx │ │ ├── Post.module.css │ │ ├── Post.tsx │ │ └── SignUp.tsx ├── hooks │ ├── index.tsx │ └── paths.tsx ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── setupTests.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode/ 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typescript-Friendly React Router Example 2 | 3 | This example shows you how to use `react-router` in more type-safe way. 4 | 5 | ## Problem 6 | 1. `react-router` takes any plain string as a path. This makes it difficult to refactor routing when it is required to rename/delete/add routes. Also typos are hard to detect. 7 | 2. Developers need to provide types for `useParams` hook (i.e. `useParams<{ id: string }>`). It has the same issue with refactoring. Developers need to update `useParams` hooks whenever there's a change in URL parameter names. 8 | 9 | ## Solution (Walkthrough) 10 | ### `src/hooks/paths.tsx` 11 | The single source of truth for available paths is defined 12 | in this module. 13 | If a route needs to be modified, this `PATHS` 14 | can be fixed, then TypeScript compiler will raise errors where 15 | type incompatibilities are found. 16 | ```tsx 17 | const PATHS = [ 18 | '/', 19 | '/signup', 20 | '/login', 21 | '/post/:id', 22 | '/calendar/:year/:month', 23 | ] as const; 24 | ``` 25 | Utility types can be derived from this readonly array of paths. 26 | ```ts 27 | type ExtractRouteParams = string extends T 28 | ? Record 29 | : T extends `${infer _Start}:${infer Param}/${infer Rest}` 30 | ? { [k in Param | keyof ExtractRouteParams]: string } 31 | : T extends `${infer _Start}:${infer Param}` 32 | ? { [k in Param]: string } 33 | : {}; 34 | 35 | export type Path = (typeof PATHS)[number]; 36 | 37 | // Object which has matching parameter keys for a path. 38 | export type PathParams

= ExtractRouteParams

; 39 | ``` 40 | Small amount of TypeScript magic is applied here, 41 | but the end result is quite simple. 42 | Note how `PathParams` type behaves. 43 | - `PathParams<'/post/:id'>` is `{ id: string }` 44 | - `PathParams<'/calendar/:year/:month'>` is `{ year: string, month: string }` 45 | - `PathParams<'/'>` is `{}` 46 | 47 | From here, a type-safe utility function is written 48 | for building URL strings. 49 | ```ts 50 | /** 51 | * Build an url with a path and its parameters. 52 | * @param path target path. 53 | * @param params parameters. 54 | */ 55 | export const buildUrl =

( 56 | path: P, 57 | params: PathParams

, 58 | ): string => { 59 | let ret: string = path; 60 | 61 | // Upcast `params` to be used in string replacement. 62 | const paramObj: { [i: string]: string } = params; 63 | 64 | for (const key of Object.keys(paramObj)) { 65 | ret = ret.replace(`:${key}`, paramObj[key]); 66 | } 67 | 68 | return ret; 69 | }; 70 | ``` 71 | `buildUrl` function can be used like this: 72 | ```ts 73 | buildUrl( 74 | '/post/:id', 75 | { id: 'abcd123' }, 76 | ); // returns '/post/abcd123' 77 | ``` 78 | `buildUrl` only takes a known path (from `PATHS`) 79 | as the first argument, 80 | therefore typo-proof. Sweet! 81 | 82 | ### `src/components/TypedLink` 83 | Now, let's look at `TypedLink` a type-safe alternative to `Link`. 84 | ```tsx 85 | import { Path, PathParams, buildUrl } from '../hooks/paths'; 86 | import React, { ComponentType, ReactNode } from 'react'; 87 | 88 | import { Link } from 'react-router-dom'; 89 | 90 | type TypedLinkProps

= { 91 | to: P, 92 | params: PathParams

, 93 | replace?: boolean, 94 | component?: ComponentType, 95 | children?: ReactNode, 96 | }; 97 | 98 | /** 99 | * Type-safe version of `react-router-dom/Link`. 100 | */ 101 | export const TypedLink =

({ 102 | to, 103 | params, 104 | replace, 105 | component, 106 | children, 107 | }: TypedLinkProps

) => { 108 | return ( 109 | 114 | {children} 115 | 116 | ); 117 | } 118 | ``` 119 | `TypedLink` can be used like this: 120 | ```tsx 121 | 122 | ``` 123 | The `to` props of `TypedLink` only takes a known path, 124 | just like `buildUrl`. 125 | 126 | ### `src/components/TypedRedirect.tsx` 127 | `TypedRedirect` is implemented in same fashion as `TypedLink`. 128 | ```tsx 129 | import { Path, PathParams, buildUrl } from '../hooks/paths'; 130 | 131 | import React from 'react'; 132 | import { Redirect } from 'react-router-dom'; 133 | 134 | type TypedRedirectProps

= { 135 | to: P, 136 | params: PathParams

, 137 | push?: boolean, 138 | from?: Q, 139 | }; 140 | 141 | /** 142 | * Type-safe version of `react-router-dom/Redirect`. 143 | */ 144 | export const TypedRedirect =

({ 145 | to, 146 | params, 147 | push, 148 | from, 149 | }: TypedRedirectProps) => { 150 | return ( 151 | 156 | ); 157 | }; 158 | ``` 159 | ### `src/hooks/index.tsx` 160 | Instead of `useParams` 161 | which cannot infer the shape of params object, 162 | `useTypedParams` hook can be used. 163 | It can infer the type of params from `path` parameter. 164 | ```ts 165 | /** 166 | * Type-safe version of `react-router-dom/useParams`. 167 | * @param path Path to match route. 168 | * @returns parameter object if route matches. `null` otherwise. 169 | */ 170 | export const useTypedParams =

( 171 | path: P 172 | ): PathParams

| null => { 173 | // `exact`, `sensitive` and `strict` options are set to true 174 | // to ensure type safety. 175 | const match = useRouteMatch({ 176 | path, 177 | exact: true, 178 | sensitive: true, 179 | strict: true, 180 | }); 181 | 182 | if (!match || !isParams(path, match.params)) { 183 | return null; 184 | } 185 | return match.params; 186 | } 187 | ``` 188 | Finally, `useTypedSwitch` allows type-safe `` tree. 189 | ```tsx 190 | /** 191 | * A hook for defining route switch. 192 | * @param routes 193 | * @param fallbackComponent 194 | */ 195 | export const useTypedSwitch = ( 196 | routes: ReadonlyArray<{ path: Path, component: ComponentType }>, 197 | fallbackComponent?: ComponentType, 198 | ): ComponentType => { 199 | const Fallback = fallbackComponent; 200 | return () => ( 201 | 202 | {routes.map(({ path, component: RouteComponent }, i) => ( 203 | 204 | 205 | 206 | ))} 207 | {Fallback && } 208 | 209 | ); 210 | } 211 | ``` 212 | Here's how `` is usually used: 213 | ```tsx 214 | // Traditional approach. 215 | const App = () => ( 216 | 217 | 218 | 219 | 220 | 221 | 222 | ); 223 | ``` 224 | The code above can be replaced with the following code. 225 | ```tsx 226 | const App = () => { 227 | const TypedSwitch = useTypedSwitch([ 228 | { path: '/', component: Home }, 229 | { path: '/user/:id', component: User }, 230 | ]); 231 | 232 | return ( 233 | 234 | 235 | 236 | ); 237 | } 238 | ``` 239 | 240 | ## Conclusion 241 | 242 | Original | Replaced 243 | ---------|--------- 244 | ``|`` 245 | ``|`` 246 | `useParams()`|`useTypedParams('/user/:id')` 247 | ``|`useTypedSwitch` 248 | 249 | Type-safe alternatives are slightly more verbose than the original syntax, 250 | but I believe this is better for overall integrity of a project. 251 | - Developers can make changes in routes 252 | without worrying about broken links (at least they don't break silently). 253 | - Nice autocompletion while editing code. 254 | 255 | ## How to Run This Example 256 | 1. Clone this repo. 257 | 2. `yarn` 258 | 3. `yarn start` 259 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "@types/react": "^16.9.53", 12 | "@types/react-dom": "^16.9.8", 13 | "react": "^17.0.1", 14 | "react-dom": "^17.0.1", 15 | "react-router-dom": "^5.2.0", 16 | "react-scripts": "4.0.1", 17 | "typescript": "^4.0.3", 18 | "web-vitals": "^0.2.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "@types/react-router-dom": "^5.1.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0916dhkim/typed-react-router/d1269049ca6be8d9c482ef3c90580612ab2a4915/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |

32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0916dhkim/typed-react-router/d1269049ca6be8d9c482ef3c90580612ab2a4915/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0916dhkim/typed-react-router/d1269049ca6be8d9c482ef3c90580612ab2a4915/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.module.css: -------------------------------------------------------------------------------- 1 | .navbar { 2 | display: flex; 3 | justify-content: space-evenly; 4 | background-color: #eee; 5 | margin-bottom: 15px; 6 | } 7 | 8 | .appContainer { 9 | padding: 15px; 10 | } 11 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { Calendar } from './components/screens/Calendar'; 5 | import { Home } from './components/screens/Home'; 6 | import { LogIn } from './components/screens/LogIn'; 7 | import { Post } from './components/screens/Post'; 8 | import { SignUp } from './components/screens/SignUp'; 9 | import { TypedLink } from './components/TypedLink'; 10 | import styles from './App.module.css'; 11 | import { useTypedSwitch } from './hooks'; 12 | 13 | // Navigation bar component. 14 | const NavBar: FC = () => { 15 | return ( 16 | 27 | ); 28 | } 29 | 30 | const App: FC = () => { 31 | // Define routes with `useTypedSwitch` hook. 32 | const TypedSwitch = useTypedSwitch([ 33 | { path: '/', component: Home }, 34 | { path: '/login', component: LogIn }, 35 | { path: '/signup', component: SignUp }, 36 | { path: '/post/:id', component: Post }, 37 | { path: '/calendar/:year/:month', component: Calendar }, 38 | ]); 39 | 40 | return ( 41 | 42 |
43 | 44 | 45 |
46 |
47 | ); 48 | } 49 | 50 | export default App; 51 | -------------------------------------------------------------------------------- /src/components/TypedLink.tsx: -------------------------------------------------------------------------------- 1 | import { Path, PathParams, buildUrl } from '../hooks/paths'; 2 | import React, { ComponentType, ReactNode } from 'react'; 3 | 4 | import { Link } from 'react-router-dom'; 5 | 6 | type TypedLinkProps

= { 7 | to: P, 8 | params: PathParams

, 9 | replace?: boolean, 10 | component?: ComponentType, 11 | children?: ReactNode, 12 | }; 13 | 14 | /** 15 | * Type-safe version of `react-router-dom/Link`. 16 | */ 17 | export const TypedLink =

({ 18 | to, 19 | params, 20 | replace, 21 | component, 22 | children, 23 | }: TypedLinkProps

) => { 24 | return ( 25 | 30 | {children} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/TypedRedirect.tsx: -------------------------------------------------------------------------------- 1 | import { Path, PathParams, buildUrl } from '../hooks/paths'; 2 | 3 | import React from 'react'; 4 | import { Redirect } from 'react-router-dom'; 5 | 6 | type TypedRedirectProps

= { 7 | to: P, 8 | params: PathParams

, 9 | push?: boolean, 10 | from?: Q, 11 | }; 12 | 13 | /** 14 | * Type-safe version of `react-router-dom/Redirect`. 15 | */ 16 | export const TypedRedirect =

({ 17 | to, 18 | params, 19 | push, 20 | from, 21 | }: TypedRedirectProps) => { 22 | return ( 23 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/screens/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from 'react'; 2 | 3 | import { useTypedParams } from '../../hooks'; 4 | 5 | export const Calendar: FC = () => { 6 | const params = useTypedParams('/calendar/:year/:month'); 7 | 8 | if (params === null) { 9 | throw new Error('[Calendar] component is rendered in invalid path.'); 10 | } 11 | 12 | const monthName = useMemo(() => { 13 | const date = new Date(params.year + '-' + params.month + '-1'); 14 | return date.toLocaleString('default', { month: 'long' }); 15 | }, [params]); 16 | 17 | return ( 18 |

19 |

Calendar

20 |

Year {params.year}

21 |

{monthName}

22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/screens/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | import { TypedLink } from '../TypedLink'; 4 | 5 | export const Home: FC = () => { 6 | const calendarData = [ 7 | [1800, 11], 8 | [1919, 3], 9 | [2015, 2], 10 | [2022, 1], 11 | ]; 12 | const postData = [1,2,3,4,5,10,100]; 13 | 14 | return ( 15 |
16 |

Welcome to Typescript react-router-dom Demo

17 |

Use the top navigation bar to look around!

18 |

Examples of using route parameters are provided below.

19 |
Calendar Component
20 |
    21 | {calendarData.map((x, i) => ( 22 |
  • 23 | 27 | {x[0].toString() + '/' + x[1].toString()} 28 | 29 |
  • 30 | ))} 31 |
32 |
Blog Posts
33 |
    34 | {postData.map(x => ( 35 |
  • 36 | 40 | Post #{x} 41 | 42 |
  • 43 | ))} 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/screens/LogIn.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, FormEvent } from 'react'; 2 | 3 | export const LogIn: FC = () => { 4 | const onSubmit = (e: FormEvent): void => { 5 | e.preventDefault(); 6 | }; 7 | 8 | return ( 9 |
10 | 14 | 18 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/screens/Post.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | word-break: break-all; 3 | } -------------------------------------------------------------------------------- /src/components/screens/Post.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from 'react'; 2 | 3 | import styles from './Post.module.css'; 4 | import { useTypedParams } from '../../hooks'; 5 | 6 | export const Post: FC = () => { 7 | const params = useTypedParams('/post/:id'); 8 | 9 | if (params === null) { 10 | throw new Error('[Post] component is rendered in invalid path.'); 11 | } 12 | 13 | const content = useMemo(() => { 14 | return Array(5).fill(params.id.repeat(1000)).map(x =>

{x}

); 15 | }, [params.id]); 16 | 17 | return ( 18 |
19 |

Blog Post #{params.id}

20 | {content} 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/screens/SignUp.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, FormEvent } from 'react'; 2 | 3 | export const SignUp: FC = () => { 4 | const onSubmit = (e: FormEvent): void => { 5 | e.preventDefault(); 6 | }; 7 | 8 | return ( 9 |
10 | 14 | 18 | 22 | 26 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/hooks/index.tsx: -------------------------------------------------------------------------------- 1 | import { Path, PathParams, isParams } from "./paths"; 2 | import { Route, Switch, useRouteMatch } from 'react-router-dom'; 3 | 4 | import { ComponentType } from "react"; 5 | 6 | /** 7 | * Type-safe version of `react-router-dom/useParams`. 8 | * @param path Path to match route. 9 | * @returns parameter object if route matches. `null` otherwise. 10 | */ 11 | export const useTypedParams =

( 12 | path: P 13 | ): PathParams

| null => { 14 | // `exact`, `sensitive` and `strict` options are set to true 15 | // to ensure type safety. 16 | const match = useRouteMatch({ 17 | path, 18 | exact: true, 19 | sensitive: true, 20 | strict: true, 21 | }); 22 | 23 | if (!match || !isParams(path, match.params)) { 24 | return null; 25 | } 26 | return match.params; 27 | } 28 | 29 | /** 30 | * A hook for defining route switch. 31 | * @example 32 | * const TypedSwitch = useTypedSwitch([ 33 | * { path: '/', component: Home }, 34 | * { path: '/user/:id', component: User }, 35 | * ]); 36 | * @param routes 37 | * @param fallbackComponent 38 | */ 39 | export const useTypedSwitch = ( 40 | routes: ReadonlyArray<{ path: Path, component: ComponentType }>, 41 | fallbackComponent?: ComponentType, 42 | ): ComponentType => { 43 | const Fallback = fallbackComponent; 44 | return () => ( 45 | 46 | {routes.map(({ path, component: RouteComponent }, i) => ( 47 | 48 | 49 | 50 | ))} 51 | {Fallback && } 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/hooks/paths.tsx: -------------------------------------------------------------------------------- 1 | const PATHS = [ 2 | '/', 3 | '/signup', 4 | '/login', 5 | '/post/:id', 6 | '/calendar/:year/:month', 7 | ] as const; 8 | 9 | type ExtractRouteParams = string extends T 10 | ? Record 11 | : T extends `${infer _Start}:${infer Param}/${infer Rest}` 12 | ? { [k in Param | keyof ExtractRouteParams]: string } 13 | : T extends `${infer _Start}:${infer Param}` 14 | ? { [k in Param]: string } 15 | : {}; 16 | 17 | export type Path = (typeof PATHS)[number]; 18 | 19 | // Object which has matching parameter keys for a path. 20 | export type PathParams

= ExtractRouteParams

; 21 | 22 | /** 23 | * Type predicate for checking whether params match the path specs. 24 | * @example 25 | * isParams( 26 | * '/something/:id', 27 | * { id: 'abcd' }, 28 | * ) // returns true. 29 | * 30 | * isParams( 31 | * '/else/:one', 32 | * { two: 'efg' }, 33 | * ) // returns false. 34 | * @param path target path. 35 | * @param params params to be checked. 36 | */ 37 | export function isParams

(path: P, params: unknown): params is PathParams

{ 38 | if (!(params instanceof Object)) { 39 | return false; 40 | } 41 | 42 | const paramSet = new Set(Object.keys(params)); 43 | 44 | // Validate params. 45 | const requiredParams = path 46 | .split('/') 47 | .filter((s) => s.startsWith(':')) 48 | .map((s) => s.substr(1)); 49 | console.log(requiredParams); 50 | 51 | for (const x of requiredParams) { 52 | if (!paramSet.has(x)) { 53 | return false; 54 | } 55 | } 56 | 57 | return true; 58 | } 59 | 60 | /** 61 | * Build an url with a path and its parameters. 62 | * @example 63 | * buildUrl( 64 | * '/a/:first/:last', 65 | * { first: 'p', last: 'q' }, 66 | * ) // returns '/a/p/q' 67 | * @param path target path. 68 | * @param params parameters. 69 | */ 70 | export const buildUrl =

( 71 | path: P, 72 | params: PathParams

, 73 | ): string => { 74 | let ret: string = path; 75 | 76 | // Upcast `params` to be used in string replacement. 77 | const paramObj: { [i: string]: string } = params; 78 | 79 | for (const key of Object.keys(paramObj)) { 80 | ret = ret.replace(`:${key}`, paramObj[key]); 81 | } 82 | 83 | return ret; 84 | }; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import-normalize; 2 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import App from './App'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import reportWebVitals from './reportWebVitals'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | 15 | // If you want to start measuring performance in your app, pass a function 16 | // to log results (for example: reportWebVitals(console.log)) 17 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 18 | reportWebVitals(); 19 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------