$30 off During Our Annual Pro Sale. View Details »

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. Advanced TypeScript: How we made our router typesafe

  2. 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!
  3. Context at Swan dashboard web-banking onboarding consent-app explorer 5 front-end

    applications
  4. What's routing in a SPA?

  5. 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
  6. Our journey of using routers paratron/hookrouter kyeotic/raviger ? abandoned breaking

    changes
  7. React Router? const App = () => ( <Routes> <Route

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

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

    userId } = useParams(); return ( <Link to={"/users/" + userId + "/books"}>Books</Link> ); }; Untypable Can have any shape Possibly undefined
  10. 404 my.app/users/unde fi ned/books

  11. Can we make routing typesafe?

  12. Union types Let's have a quick look at

  13. type Literal = string | number | boolean; Union types

  14. 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
  15. 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
  16. const fn = (animal: Animal) => { if (animal.tag ===

    "cat") { animal.isVicious; animal.isAGoodBoy; } else { animal.isVicious; animal.isAGoodBoy; } }; Union types Error Error
  17. Wouldn't it be great to have this for routing?

  18. Wouldn't it be great to have this for routing?

  19. The dream API const routes = { Users: "/users", User:

    "/users/:userId ?: invitationCode", Books: "/users/:userId/books", Book: "/users/:userId/books/:bookId", };
  20. 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 } };
  21. How do we get there?

  22. TYPESCRIPT ZOONTEK

  23. brace yourselves

  24. Disclaimer: We're using types like functions

  25. Feature #1 Template literal types

  26. Template literal types type User = { firstName: string; lastName:

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

    "Saison"; }>; "Michel Saison" The type is narrowed down to
  28. Feature #2 Conditional types

  29. Conditional types type IsInvited<User extends string> = User extends "Manuel

    Valls" ? "No" : "Yes";
  30. Conditional types IsInvited<"Manuel Valls">; IsInvited<"Thomas Pesquet">; "No" 😌 "Yes" Type:

    Type:
  31. Feature #3 The `infer` keyword

  32. The `infer` keyword type RemoveHashtag<Input extends string> = Input extends

    `#${infer Text}` ? Text : Input; Infer value after #
  33. The `infer` keyword RemoveHashtag<"#FollowFriday">; RemoveHashtag<"TopChef">; "FollowFriday" "TopChef" Type: Type:

  34. Feature #4 Recursive type calls

  35. 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
  36. Recursive type calls type Atreides = GetDescendants<{ name: "Paulus"; child:

    { name: "Leto"; child: { name: "Paul"; }; }; }>; ["Paulus", "Leto", "Paul"] Type:
  37. Now that we have the tools

  38. What a route can be "/users/zoontek" "/users/zoontek?invitationCode=1234" "/users/zoontek?invitationCode=1234#profile" "/users/zoontek#profile" path

    path + search path + search + hash path + hash
  39. 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: "" };
  40. 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: "" };
  41. 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
  42. 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
  43. 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
  44. 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
  45. 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">;
  46. 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
  47. Splitting our URL segments (or embracing all the madness) Split<"/users/:userId",

    "/"> Split<":invitationCode&:token", "&"> ["", "users", ":userId"] [":invitationCode", ":token"] Type: Type:
  48. 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> : {};
  49. 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
  50. 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
  51. 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 ":")
  52. 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
  53. 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
  54. 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
  55. 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
  56. Building our params object ExtractPathParams<"/users/:userId/books/:bookId"> { userId: string; bookId: string

    } Type:
  57. 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> : {};
  58. 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
  59. 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"] }
  60. Building our hash object type ExtractHashParams<Hash extends string> = Hash

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

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

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

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

    do it in your app code) (I mean it)
  65. But thankfully we've made a library out of that! Demo

    time!
  66. Other features • Safe link creation • Keyboard focus reset

    ♿ • Navigation blocking • Server-side rendering • …
  67. github.com/swan-io/chicane yarn add @swan-io/chicane

  68. None
  69. Thank you! Questions? ✋ Mathieu Acthernoene @zoontek Co-lead front-end developer

    at swan.io