Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Advanced TypeScript: How we made our router typesafe

Advanced TypeScript: How we made our router typesafe

From ParisJS #99

Mathieu Acthernoene

May 30, 2022
Tweet

More Decks by Mathieu Acthernoene

Other Decks in Technology

Transcript

  1. Mathieu Acthernoene @zoontek • Co-lead front-end developer at swan.io •

    Podcast host at Putain de Code • Maintainer of react-native-(permissions / localize / bootsplash) We're hiring!
  2. What's routing in a SPA? 1. The server sends the

    same HTML file 2. The JS boots and reads the URL 3. You display content based on that URL 4. When navigating, JS "intercepts" events
  3. React Router? const App = () => ( <Routes> <Route

    path="/users/:userId" element={<Profile />} /> </Routes> );
  4. React Router? const App = () => ( <Routes> <Route

    path="/users/:userId" element={<Profile />} /> </Routes> ); Weak link
  5. React Router? const Profile = () => { const {

    userId } = useParams(); return ( <Link to={"/users/" + userId + "/books"}>Books</Link> ); }; Untypable Can have any shape Possibly undefined
  6. Union types const fn = (input: Literal) => { if

    (typeof input === "string") { return input.toLowerCase(); } if (typeof input === "number") { return Math.round(input); } return input; }; TS knows it's a string TS knows it's a number TS knows it's a boolean It's the only other possibility
  7. type Cat = { tag: "cat"; isVicious: boolean; }; type

    Dog = { tag: "dog"; isAGoodBoy: boolean; }; type Animal = Cat | Dog; Union types Value as type The type system can guard based on that key
  8. const fn = (animal: Animal) => { if (animal.tag ===

    "cat") { animal.isVicious; animal.isAGoodBoy; } else { animal.isVicious; animal.isAGoodBoy; } }; Union types Error Error
  9. The dream API const routes = { Users: "/users", User:

    "/users/:userId ?: invitationCode", Books: "/users/:userId/books", Book: "/users/:userId/books/:bookId", };
  10. The dream typing type Route = | { name: "Users",

    params: {} } | { name: "User", params: { userId: string, invitationCode ?: string } } | { name: "Books", params: { userId: string } } | { name: "Book", params: { userId: string, bookId: string } };
  11. Template literal types type User = { firstName: string; lastName:

    string; }; type GetFullName<T extends User> = `${T["firstName"]} ${T["lastName"]}`;
  12. Template literal types type MichelSaison = GetFullName<{ firstName: "Michel"; lastName:

    "Saison"; }>; "Michel Saison" The type is narrowed down to
  13. The `infer` keyword type RemoveHashtag<Input extends string> = Input extends

    `#${infer Text}` ? Text : Input; Infer value after #
  14. Recursive type calls type Person = { name: string; child

    ?: Person; }; type GetFamilyNames<T extends Person> = [ T["name"], ... (T["child"] extends Person ? GetFamilyNames<T["child"]> : []), ]; Calls itself
  15. Recursive type calls type Atreides = GetDescendants<{ name: "Paulus"; child:

    { name: "Leto"; child: { name: "Paul"; }; }; }>; ["Paulus", "Leto", "Paul"] Type:
  16. Let's extract the route types type ExtractRoute<Route extends string> =

    Route extends `${infer Path}?${infer Search}#${infer Hash}` ? { path: Path; search: Search; hash: Hash } : Route extends `${infer Path}?${infer Search}` ? { path: Path; search: Search; hash: "" } : Route extends `${infer Path}#${infer Hash}` ? { path: Path; search: ""; hash: Hash } : { path: Route; search: ""; hash: "" };
  17. Let's extract the route types type ExtractRoute<Route extends string> =

    Route extends `${infer Path}?${infer Search}#${infer Hash}` ? { path: Path; search: Search; hash: Hash } : Route extends `${infer Path}?${infer Search}` ? { path: Path; search: Search; hash: "" } : Route extends `${infer Path}#${infer Hash}` ? { path: Path; search: ""; hash: Hash } : { path: Route; search: ""; hash: "" };
  18. Let's extract the route types type ExtractRoute<Route extends string> =

    Route extends `${infer Path}?${infer Search}#${infer Hash}` ? { path: Path; search: Search; hash: Hash } : Route extends `${infer Path}?${infer Search}` ? { path: Path; search: Search; hash: "" } : Route extends `${infer Path}#${infer Hash}` ? { path: Path; search: ""; hash: Hash } : { path: Route; search: ""; hash: "" }; path
  19. Let's extract the route types type ExtractRoute<Route extends string> =

    Route extends `${infer Path}?${infer Search}#${infer Hash}` ? { path: Path; search: Search; hash: Hash } : Route extends `${infer Path}?${infer Search}` ? { path: Path; search: Search; hash: "" } : Route extends `${infer Path}#${infer Hash}` ? { path: Path; search: ""; hash: Hash } : { path: Route; search: ""; hash: "" }; path + hash
  20. Let's extract the route types type ExtractRoute<Route extends string> =

    Route extends `${infer Path}?${infer Search}#${infer Hash}` ? { path: Path; search: Search; hash: Hash } : Route extends `${infer Path}?${infer Search}` ? { path: Path; search: Search; hash: ""} : Route extends `${infer Path}#${infer Hash}` ? { path: Path; search: ""; hash: Hash } : { path: Route; search: ""; hash: "" }; path + search
  21. Let's extract the route types type ExtractRoute<Route extends string> =

    Route extends `${infer Path}?${infer Search}#${infer Hash}` ? { path: Path; search: Search; hash: Hash } : Route extends `${infer Path}?${infer Search}` ? { path: Path; search: Search; hash: ""} : Route extends `${infer Path}#${infer Hash}` ? { path: Path; search: ""; hash: Hash } : { path: Route; search: ""; hash: "" }; path + search + hash
  22. Let's extract the route types ExtractRoute<"/users/:userId ?: invitationCode">; { path:

    "/users/:userId"; search: ":invitationCode"; hash: ""; } Type: { path: "/books/:bookId"; search: ""; hash: ":title"; } Type: ExtractRoute<"/books/:bookId#:title">;
  23. Splitting our URL segments (or embracing all the madness) type

    Split< T extends string, Separator extends string, > = T extends `${infer Head}${Separator}${infer Tail}` ? [Head, ... Split<Tail, Separator>] : [T]; Recursion Template literal type Infer Conditional type
  24. Splitting our URL segments (or embracing all the madness) Split<"/users/:userId",

    "/"> Split<":invitationCode&:token", "&"> ["", "users", ":userId"] [":invitationCode", ":token"] Type: Type:
  25. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {};
  26. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; Use default generic to split the path
  27. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; Check if there's an item left in the split path
  28. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; Check if the segment is a path param (starting with ":")
  29. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; If so, a the param to our params object
  30. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; And continue with the rest of the path
  31. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; If it doesn't look like a param, continue with the rest of the path
  32. Building our params object type ExtractPathParams< Path extends string, Items

    = Split<Path, "/">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}` ? { [_ in Name]: string } & ExtractPathParams<Path, Tail> : ExtractPathParams<Path, Tail> : {}; If we reached the end of the split path, return an empty object
  33. Building our search object type ExtractSearchParams< Search extends string, Items

    = Split<Search, "&">, > = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}[]` ? { [_ in Name] ?: string[] } & ExtractSearchParams<Search, Tail> : Head extends `:${infer Name}` ? { [_ in Name] ?: string } & ExtractSearchParams<Search, Tail> : ExtractSearchParams<Search, Tail> : {};
  34. type ExtractSearchParams< Search extends string, Items = Split<Search, "&">, >

    = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}[]` ? { [_ in Name] ?: string[] } & ExtractSearchParams<Search, Tail> : Head extends `:${infer Name}` ? { [_ in Name] ?: string } & ExtractSearchParams<Search, Tail> : ExtractSearchParams<Search, Tail> : {}; Building our search object More or less the same thing
  35. type ExtractSearchParams< Search extends string, Items = Split<Search, "&">, >

    = Items extends [infer Head, ... infer Tail] ? Head extends `:${infer Name}[]` ? { [_ in Name] ?: string[] } & ExtractSearchParams<Search, Tail> : Head extends `:${infer Name}` ? { [_ in Name] ?: string } & ExtractSearchParams<Search, Tail> : ExtractSearchParams<Search, Tail> : {}; Building our search object Only a special case for the :param[] syntax, indicating we expect an array "?type=cat&type=dog" { type: ["cat", "dog"] }
  36. Building our hash object type ExtractHashParams<Hash extends string> = Hash

    extends `:${infer Name}` ? { [_ in Name] ?: string } : {};
  37. Building our hash object type ExtractHashParams<Hash extends string> = Hash

    extends `:${infer Name}` ? { [_ in Name] ?: string } : {}; I think you get the idea now
  38. Merging everything type ExtractRouteParams< Route extends string, ExtractedRoute extends RouteObject

    = ExtractRoute<Route>, > = ExtractPathParams<ExtractedRoute["path"]> & ExtractSearchParams<ExtractedRoute["search"]> & ExtractHashParams<ExtractedRoute["hash"]>;
  39. Merging everything { userId: string, bookId: string, foo ?: string,

    bar ?: string[], baz ?: string, }; ExtractRouteParams<"/users/:userId/books/:bookId ?: foo&:bar[]#:baz">; Type:
  40. And that's how you get a fully typesafe Router (don't

    do it in your app code) (I mean it)
  41. Other features • Safe link creation • Keyboard focus reset

    ♿ • Navigation blocking • Server-side rendering • …