Created
April 11, 2023 06:36
-
-
Save ycmjason/b12ae779e55b17c430b7541268fd9333 to your computer and use it in GitHub Desktop.
A fancy way to type your react router config
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const routes = [ | |
{ | |
path: '/', | |
element: <App />, | |
errorElement: <CircularProgress />, | |
children: [ | |
{ | |
index: true, | |
element: <LandingPage />, | |
}, | |
{ | |
path: 'open-table', | |
element: <OpenTablePage />, | |
children: [ | |
{ | |
index: true, | |
Component: () => { | |
useEffectOnce(() => { | |
logEvent(analytics, 'new_game'); | |
}); | |
return <Navigate to={ROUTES.OPEN_TABLE__SCORING_SETTINGS} replace />; | |
}, | |
}, | |
{ | |
path: 'scoring-settings', | |
Component: () => { | |
const navigate = useNavigate(); | |
return <ScoringSettingsStep onNext={() => navigate(ROUTES.OPEN_TABLE__PLAYERS)} />; | |
}, | |
}, | |
{ | |
path: 'players', | |
Component: () => { | |
const navigate = useNavigate(); | |
return ( | |
<PlayersStep | |
onBack={() => navigate(ROUTES.OPEN_TABLE__SCORING_SETTINGS)} | |
onNext={() => navigate(ROUTES.OPEN_TABLE__FIRST_WU)} | |
/> | |
); | |
}, | |
}, | |
{ | |
path: 'first-wu', | |
Component: () => { | |
const navigate = useNavigate(); | |
return ( | |
<FirstWuStep | |
onBack={() => navigate(ROUTES.OPEN_TABLE__PLAYERS)} | |
onNext={() => navigate(ROUTES.GAMES__$GID__CHART({ gid: 'hi' }))} | |
/> | |
); | |
}, | |
}, | |
], | |
}, | |
{ | |
path: 'games/:gid', | |
Component: () => { | |
const { gid } = useParams(); | |
if (!gid) throw new Error(`cannot find game ${gid}`); | |
return <GamePage gid={gid} />; | |
}, | |
children: [ | |
{ | |
path: 'chart', | |
element: <ChartPage />, | |
}, | |
{ | |
path: 'table', | |
element: <TablePage />, | |
}, | |
{ | |
path: 'settings', | |
element: <SettingsPage />, | |
}, | |
], | |
}, | |
], | |
}, | |
] as const; | |
export const ROUTES = extractRoutes(routes); | |
/* | |
ROUTES :: { | |
HOME: "/"; | |
OPEN_TABLE: "/open-table"; | |
OPEN_TABLE__SCORING_SETTINGS: "/open-table/scoring-settings"; | |
OPEN_TABLE__PLAYERS: "/open-table/players"; | |
OPEN_TABLE__FIRST_WU: "/open-table/first-wu"; | |
GAMES__$GID__CHART: <const O extends Readonly<...>>(payload: O) => `/games/${"gid" extends keyof O ? O[keyof O & "gid"] : string}/chart`; | |
GAMES__$GID__TABLE: <const O extends Readonly<...>>(payload: O) => `/games/${"gid" extends keyof O ? O[keyof O & "gid"] : string}/table`; | |
GAMES__$GID__SETTINGS: <const O extends Readonly<...>>(payload: O) => `/games/${"gid" extends keyof O ? O[keyof O & "gid"] : string}/settings`; | |
} | |
ROUTES.GAMES__$GID__CHART({ gid: 'hi' }) // `/games/hi/chart` | |
*/ | |
export const router = createBrowserRouter(routes as unknown as RouteObject[]); // removing readonly since createBrowserRouter is not happy |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { isUndefined } from 'ramda-adjunct'; | |
const throwError = (error: unknown): never => { | |
throw error; | |
}; | |
type IsNever<T> = [T] extends [never] ? true : false; | |
type IndexRoute = Readonly<{ index: true }>; | |
type RouteLike = Readonly<{ path: string; children?: readonly ChildRouteLike[] }>; | |
type ChildRouteLike = RouteLike | IndexRoute; | |
const SECTION_SEPERATOR = '__' as const; | |
type UppercaseSnake<T extends string> = Replace<'-', '_', Uppercase<T>>; | |
type NormalizePath<P extends string> = P extends `/` | |
? '/' | |
: P extends `${infer P1}//${infer P2}` | |
? NormalizePath<`${P1}/${P2}`> | |
: P extends `${infer P1}/` | |
? NormalizePath<`${P1}`> | |
: P; | |
type Replace< | |
X extends string, | |
Y extends string, | |
S extends string, | |
> = S extends `${infer S1}${X}${infer S2}` ? Replace<X, Y, `${S1}${Y}${S2}`> : S; | |
type PathFromRoutes< | |
Rs extends readonly ChildRouteLike[], | |
Root extends string = '/', | |
> = NormalizePath< | |
Rs[number] extends infer R | |
? R extends IndexRoute | |
? Root | |
: R extends Required<RouteLike> | |
? PathFromRoutes<R['children'], NormalizePath<`${Root}/${R['path']}`>> | |
: R extends RouteLike | |
? NormalizePath<`${Root}/${R['path']}`> | |
: never | |
: never | |
>; | |
type PathToRouteName<P extends string> = P extends `${infer P1}/:${infer P2}/${infer P3}` | |
? PathToRouteName<`${P1}/$${P2}/${P3}`> | |
: P extends `:${infer P1}/${infer P2}` | |
? PathToRouteName<`$${P1}/${P2}`> | |
: P extends `:${infer P1}` | |
? PathToRouteName<`$${P1}`> | |
: P extends `${infer P1}/:${infer P2}` | |
? PathToRouteName<`${P1}/$${P2}`> | |
: P extends `/${infer P1}` | |
? PathToRouteName<P1> | |
: Replace<'/', typeof SECTION_SEPERATOR, UppercaseSnake<P>>; | |
type GetParams<R extends string> = R extends `${string}/:${infer P1}/${infer P2}` | |
? P1 | GetParams<P2> | |
: R extends `:${infer P1}/${infer P2}` | |
? P1 | GetParams<P2> | |
: R extends `:${infer P1}` | |
? P1 | |
: R extends `${string}/:${infer P1}` | |
? P1 | |
: never; | |
type ReplaceParamsWithStrings< | |
R extends string, | |
O extends Readonly<Record<GetParams<R>, string>>, | |
> = R extends `${infer P1}/:${infer N}/${infer P2}` | |
? `${P1}/${N extends keyof O ? O[N] : string}/${P2}` | |
: R extends `:${infer N}/${infer P1}` | |
? `${N extends keyof O ? O[N] : string}/${P1}` | |
: R extends `:${infer N}` | |
? `${N extends keyof O ? O[N] : string}` | |
: R extends `${infer P1}/:${infer N}` | |
? `${P1}/${N extends keyof O ? O[N] : string}` | |
: R; | |
type ExtractRoutes<Rs extends readonly ChildRouteLike[]> = { | |
[R in PathFromRoutes<Rs> as R extends '/' ? 'HOME' : PathToRouteName<R>]: IsNever< | |
GetParams<R> | |
> extends true | |
? R | |
: <const O extends Readonly<Record<GetParams<R>, string>>>( | |
payload: O, | |
) => ReplaceParamsWithStrings<R, O>; | |
}; | |
const normalizePath = <P extends string>(p: P): NormalizePath<P> => | |
p.replaceAll(/\/+/g, '/').replace(/(?<=.)\/+$/, '') as NormalizePath<P>; | |
const extractPaths = <R extends readonly ChildRouteLike[]>( | |
routes: R, | |
root = '/', | |
): PathFromRoutes<R>[] => { | |
return routes.flatMap(route => { | |
if ('index' in route) return []; | |
const absolutePath = normalizePath(`${root}/${route.path}`); | |
return [ | |
absolutePath, | |
...(isUndefined(route.children) ? [] : extractPaths(route.children, absolutePath)), | |
]; | |
}) as PathFromRoutes<R>[]; | |
}; | |
export const extractRoutes = <R extends readonly ChildRouteLike[]>(routes: R): ExtractRoutes<R> => { | |
return Object.fromEntries( | |
extractPaths(routes).map(path => [ | |
path | |
.toLocaleUpperCase() | |
.replaceAll(/(?<=\/|^):([^/]*)/g, '$$$1') | |
.replace(/^\//, '') | |
.replaceAll(/\//g, SECTION_SEPERATOR) | |
.replaceAll(/-/g, '_') || 'HOME', | |
(() => { | |
if (!/(\/|^):[^/]+/.test(path)) return path; | |
return (payload: Record<string, string>) => | |
path.replaceAll( | |
/(?<=\/|^):([^/]*)/g, | |
(_, paramName) => payload[paramName] ?? throwError(new Error('no route payload given')), | |
); | |
})(), | |
]), | |
) as ExtractRoutes<R>; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment