Skip to content

Instantly share code, notes, and snippets.

@ycmjason
Created April 11, 2023 06:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ycmjason/b12ae779e55b17c430b7541268fd9333 to your computer and use it in GitHub Desktop.
Save ycmjason/b12ae779e55b17c430b7541268fd9333 to your computer and use it in GitHub Desktop.
A fancy way to type your react router config
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
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