Skip to content

Commit 29b28e0

Browse files
pcattoriharshit-d3vgrzdev
authored
Improved types for generatePaths params arg (#14984)
Co-authored-by: harshit-d3v <programming2hars@gmail.com> Co-authored-by: Dami Oyeniyi <damilolaoyeniyi13@gmail.com>
1 parent 142c703 commit 29b28e0

3 files changed

Lines changed: 117 additions & 53 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
Improved types for `generatePath`'s `param` arg
2+
3+
Type errors when required params are omitted:
4+
5+
```ts
6+
// Before
7+
// Passes type checks, but throws at runtime 💥
8+
generatePath(':required', { required: null })
9+
10+
// After
11+
generatePath(':required', { required: null })
12+
// ^^^^^^^^ Type 'null' is not assignable to type 'string'.ts(2322)
13+
```
14+
15+
Allow omission of optional params:
16+
17+
```ts
18+
// Before
19+
generatePath(':optional?', {})
20+
// ^^ Property 'optional' is missing in type '{}' but required in type '{ optional: string | null | undefined; }'.ts(2741)
21+
22+
// After
23+
generatePath(':optional?', {})
24+
```
25+
26+
Allows extra keys:
27+
28+
```ts
29+
// Before
30+
generatePath(":a", { a: "1", b: "2" })
31+
// ^ Object literal may only specify known properties, and 'b' does not exist in type '{ a: string; }'.ts(2353)
32+
33+
// After
34+
generatePath(":a", { a: "1", b: "2" })
35+
```

packages/react-router/lib/hooks.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,8 @@ export function useNavigationType(): NavigationType {
185185
* @returns The path match object if the pattern matches, `null` otherwise
186186
*/
187187
export function useMatch<
188-
ParamKey extends ParamParseKey<Path>,
189188
Path extends string,
190-
>(pattern: PathPattern<Path> | Path): PathMatch<ParamKey> | null {
189+
>(pattern: PathPattern<Path> | Path): PathMatch<ParamParseKey<Path>> | null {
191190
invariant(
192191
useInRouterContext(),
193192
// TODO: This error is probably because they somehow have 2 versions of the
@@ -197,7 +196,7 @@ export function useMatch<
197196

198197
let { pathname } = useLocation();
199198
return React.useMemo(
200-
() => matchPath<ParamKey, Path>(pattern, decodePath(pathname)),
199+
() => matchPath<Path>(pattern, decodePath(pathname)),
201200
[pathname, pattern],
202201
);
203202
}
@@ -667,7 +666,7 @@ export function useParams<
667666
> {
668667
let { matches } = React.useContext(RouteContext);
669668
let routeMatch = matches[matches.length - 1];
670-
return routeMatch ? (routeMatch.params as any) : {};
669+
return (routeMatch?.params ?? {}) as any;
671670
}
672671

673672
/**

packages/react-router/lib/router/utils.ts

Lines changed: 79 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -770,49 +770,63 @@ export type RouteManifest<R = DataRouteObject> = Record<string, R | undefined>;
770770

771771
// prettier-ignore
772772
type Regex_az = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"
773-
// prettier-ignore
774-
type Regez_AZ = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "I" | "J" | "K" | "L" | "M" | "N" | "O" | "P" | "Q" | "R" | "S" | "T" | "U" | "V" | "W" | "X" | "Y" | "Z"
773+
type Regex_AZ = Uppercase<Regex_az>;
775774
type Regex_09 = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
776-
type Regex_w = Regex_az | Regez_AZ | Regex_09 | "_";
777-
type ParamChar = Regex_w | "-";
778-
779-
// Emulates regex `+`
780-
type RegexMatchPlus<
781-
CharPattern extends string,
782-
T extends string,
783-
> = T extends `${infer First}${infer Rest}`
784-
? First extends CharPattern
785-
? RegexMatchPlus<CharPattern, Rest> extends never
786-
? First
787-
: `${First}${RegexMatchPlus<CharPattern, Rest>}`
788-
: never
789-
: never;
790-
791-
// Recursive helper for finding path parameters in the absence of wildcards
792-
type _PathParam<Path extends string> =
793-
// split path into individual path segments
794-
Path extends `${infer L}/${infer R}`
795-
? _PathParam<L> | _PathParam<R>
796-
: // find params after `:`
797-
Path extends `:${infer Param}`
798-
? Param extends `${infer Optional}?${string}`
799-
? RegexMatchPlus<ParamChar, Optional>
800-
: RegexMatchPlus<ParamChar, Param>
801-
: // otherwise, there aren't any params present
802-
never;
803-
804-
export type PathParam<Path extends string> =
775+
type Regex_w = Regex_az | Regex_AZ | Regex_09 | "_";
776+
777+
// prettier-ignore
778+
/** Emulates Regex `+` operator */
779+
type RegexMatchPlus<char extends string, T extends string> =
780+
_RegexMatchPlus<char, T> extends infer result extends string ?
781+
result extends '' ? never : result
782+
:
783+
never
784+
785+
// prettier-ignore
786+
type _RegexMatchPlus<char extends string, T extends string> =
787+
T extends `${infer head extends char}${infer rest}` ?
788+
`${head}${_RegexMatchPlus<char, rest>}`
789+
:
790+
''
791+
792+
type ParamNameChar = Regex_w | "-";
793+
794+
type Simplify<T> = { [K in keyof T]: T[K] } & {};
795+
796+
// prettier-ignore
797+
type GeneratePathParams<path extends string> = Simplify<
798+
& ParseParams<path>
799+
& { [key in string]: string | null | undefined }
800+
>
801+
802+
// prettier-ignore
803+
type ParseParams<path extends string> =
805804
// check if path is just a wildcard
806-
Path extends "*" | "/*"
807-
? "*"
808-
: // look for wildcard at the end of the path
809-
Path extends `${infer Rest}/*`
810-
? "*" | _PathParam<Rest>
811-
: // look for params in the absence of wildcards
812-
_PathParam<Path>;
805+
path extends '*' ? { '*': string } :
806+
// look for wildcard at the end of the path
807+
path extends `${infer rest}/*` ? { '*': string } & ParseParams<rest> :
808+
// look for params in the absence of wildcards
809+
_ParseParams<path>;
810+
811+
// prettier-ignore
812+
type _ParseParams<path extends string> =
813+
// split path into individual path segments
814+
path extends `${infer left}/${infer right}` ?
815+
_ParseParams<left> & _ParseParams<right> :
816+
// look for optional param in segment
817+
path extends `:${infer param}?${string}` ?
818+
{ [key in RegexMatchPlus<ParamNameChar, param>]?: string | null | undefined } :
819+
// look for required param in segment
820+
path extends `:${infer param}` ?
821+
{ [key in RegexMatchPlus<ParamNameChar, param>]: string } :
822+
{};
823+
824+
// prettier-ignore
825+
export type PathParam<path extends string> = (keyof ParseParams<path>) & string;
813826

814827
// eslint-disable-next-line @typescript-eslint/no-unused-vars
815828
type _tests = [
829+
// PathParam
816830
Expect<Equal<PathParam<"/a/b/*">, "*">>,
817831
Expect<Equal<PathParam<":a">, "a">>,
818832
Expect<Equal<PathParam<"/a/:b">, "b">>,
@@ -821,6 +835,28 @@ type _tests = [
821835
Expect<Equal<PathParam<"/:a/b/:c/*">, "a" | "c" | "*">>,
822836
Expect<Equal<PathParam<"/:lang.xml">, "lang">>,
823837
Expect<Equal<PathParam<"/:lang?.xml">, "lang">>,
838+
839+
// ParseParams
840+
Expect<Equal<ParseParams<"/a/b/*">, { "*": string }>>,
841+
Expect<Equal<ParseParams<":a">, { a: string }>>,
842+
Expect<Equal<ParseParams<"/a/:b">, { b: string }>>,
843+
Expect<Equal<ParseParams<"/a/blahblahblah:b">, {}>>,
844+
Expect<Equal<Simplify<ParseParams<"/:a/:b">>, { a: string; b: string }>>,
845+
Expect<
846+
Equal<
847+
Simplify<ParseParams<"/:a/b/:c/*">>,
848+
{ a: string; c: string; "*": string }
849+
>
850+
>,
851+
Expect<Equal<ParseParams<"/:lang.xml">, { lang: string }>>,
852+
Expect<
853+
Equal<ParseParams<"/:lang?.xml">, { lang?: string | null | undefined }>
854+
>,
855+
Expect<Equal<Simplify<ParseParams<"/:a/:a">>, { a: string }>>,
856+
Expect<Equal<Simplify<ParseParams<"/:a/:a?">>, { a: string }>>,
857+
Expect<
858+
Equal<Simplify<ParseParams<"/:a?/:a?">>, { a?: string | null | undefined }>
859+
>,
824860
];
825861

826862
// Attempt to parse the given string segment. If it fails, then just return the
@@ -1365,9 +1401,7 @@ function matchRouteBranch<
13651401
*/
13661402
export function generatePath<Path extends string>(
13671403
originalPath: Path,
1368-
params: {
1369-
[key in PathParam<Path>]: string | null;
1370-
} = {} as any,
1404+
params: GeneratePathParams<Path> = {} as any,
13711405
): string {
13721406
let path: string = originalPath;
13731407
if (path.endsWith("*") && path !== "*" && !path.endsWith("/*")) {
@@ -1394,15 +1428,14 @@ export function generatePath<Path extends string>(
13941428

13951429
// only apply the splat if it's the last segment
13961430
if (isLastSegment && segment === "*") {
1397-
const star = "*" as PathParam<Path>;
13981431
// Apply the splat
1399-
return stringify(params[star]);
1432+
return stringify(params["*" as keyof typeof params]);
14001433
}
14011434

14021435
const keyMatch = segment.match(/^:([\w-]+)(\??)(.*)/);
14031436
if (keyMatch) {
14041437
const [, key, optional, suffix] = keyMatch;
1405-
let param = params[key as PathParam<Path>];
1438+
let param = params[key as keyof typeof params];
14061439
invariant(optional === "?" || param != null, `Missing ":${key}" param`);
14071440
return encodeURIComponent(stringify(param)) + suffix;
14081441
}
@@ -1477,13 +1510,10 @@ type Mutable<T> = {
14771510
* @returns A path match object if the pattern matches the pathname,
14781511
* or `null` if it does not match.
14791512
*/
1480-
export function matchPath<
1481-
ParamKey extends ParamParseKey<Path>,
1482-
Path extends string,
1483-
>(
1513+
export function matchPath<Path extends string>(
14841514
pattern: PathPattern<Path> | Path,
14851515
pathname: string,
1486-
): PathMatch<ParamKey> | null {
1516+
): PathMatch<ParamParseKey<Path>> | null {
14871517
if (typeof pattern === "string") {
14881518
pattern = { path: pattern, caseSensitive: false, end: true };
14891519
}

0 commit comments

Comments
 (0)