Description
TypeScript Version: Testing in both 2.9.2 && 3.0.0-dev.201xxxxx
Search Terms:
generics, jsx, conditional types
Code
I am trying to write a typesafe wrapper around React-Router's Link component. I would like to make the component generic to my routes map, while providing a clean API for consumers of the component.
If the route has no parameters, I want the component to only require the routeName
prop, while routes with parameters require both the routeName
and params
associated with that route object passed in as props.
import * as React from 'react';
import { Route as IRoute, RouteParams, param } from '../interfaces/types';
import { Link as ReactRouterLink } from 'react-router-dom';
import { route } from '../route';
type LinkWithNoParams<T extends Record<string, IRoute<any>>, K extends keyof T> = {
routeName: K;
};
type LinkWithParams<T extends Record<string, IRoute<any>>, K extends keyof T> = {
routeName: K;
// extracts the "param" values from a route tuple
params: RouteParams<T[K]>;
};
export type LinkProps<
T extends Record<string, IRoute<any>>,
K extends keyof T
> = keyof RouteParams<T[K]> extends never ? LinkWithNoParams<T, K> : LinkWithParams<T, K>;
function hasParams(e: any): e is LinkWithParams<any, any> {
return e && e.params;
}
export const createLink = <T extends Record<string, IRoute<any>>>(routes: T) => {
function Link<K extends keyof T>(props: LinkProps<T, K>) {
if (hasParams(props)) {
return <ReactRouterLink to={routes[props.routeName].create(props.params)} />;
} else {
return <ReactRouterLink to={routes[props.routeName].create({})} />;
}
}
return Link;
};
// Used as our discriminant
enum RouteNames {
VIEW = 'VIEW',
VIEW_DETAILS = 'VIEW_DETAILS',
}
const Routes = {
[RouteNames.VIEW]: route(['view']),
[RouteNames.VIEW_DETAILS]: route(['view', param('id'), param('otherId')]),
};
const Link = createLink(Routes);
// Conditional Type Sanity Check
type ViewRouteParams = keyof RouteParams<typeof Routes[RouteNames.VIEW]>; // type is never
type ViewDetailsRouteParams = keyof RouteParams<typeof Routes[RouteNames.VIEW_DETAILS]>; // type is "id" | "otherId"
type ViewLinkProps = LinkProps<typeof Routes, RouteNames.VIEW>; // type is { routeName: RouteNames.VIEW }
type ViewDetailsLinkProps = LinkProps<typeof Routes, RouteNames.VIEW_DETAILS>;
// type is { routeName: RouteNames.VIEW_DETAILS, params: { id: string, otherId: string } }
// "correct" behavior
const correct = <Link routeName={RouteNames.VIEW} /> // no error
const correct2 = <Link routeName={RouteNames.VIEW} params={{}} />// params does not exist on type ...
const correct3 = <Link<RouteNames.VIEW_DETAILS> routeName={RouteNames.VIEW_DETAILS} />; // error of "property params is missing in type.."
const correct4 = (
<Link<RouteNames.VIEW_DETAILS> routeName={RouteNames.VIEW_DETAILS} params={{}} />
); // error of "property id is missing in type..."
// "incorrect" behavior
const SHOULD_FAIL_BUT_DOESNT = <Link routeName={RouteNames.VIEW_DETAILS} />; // expect failure "property params is missing in type..."
const SHOULD_NOT_FAIL_BUT_DOES = <Link routeName={RouteNames.VIEW_DETAILS} params={{}} />;
// property params doesnt not exist on type `IntrinsicAttributes & LinkWithNoParams
Expected behavior:
Link does not require explicit generic argument--can infer based off the routeName prop passed to the JSX component.
Actual behavior:
Link required explicitly passing in generic argument.
Related Issues:
Possibly #23412