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. Advanced TypeScript:


    How we made our


    router typesafe

    View Slide

  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!

    View Slide

  3. Context at Swan
    dashboard web-banking onboarding
    consent-app
    explorer
    5 front-end applications

    View Slide

  4. What's routing in a SPA?

    View Slide

  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

    View Slide

  6. Our journey of using routers
    paratron/hookrouter
    kyeotic/raviger
    ?
    abandoned
    breaking


    changes

    View Slide

  7. React Router?
    const App = ()
    =>
    (





    } />





    );

    View Slide

  8. React Router?
    const App = ()
    =>
    (





    } />





    );
    Weak link

    View Slide

  9. React Router?
    const Profile = ()
    =>
    {


    const { userId } = useParams();


    return (


    Books


    );


    };
    Untypable
    Can have any shape
    Possibly undefined

    View Slide

  10. 404
    my.app/users/unde
    fi
    ned/books

    View Slide

  11. Can we make routing


    typesafe?

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  16. const fn = (animal: Animal)
    =>
    {


    if (animal.tag
    ===
    "cat") {


    animal.isVicious;


    animal.isAGoodBoy;


    } else {


    animal.isVicious;


    animal.isAGoodBoy;


    }


    };
    Union types
    Error
    Error

    View Slide

  17. Wouldn't it be great


    to have this for routing?

    View Slide

  18. Wouldn't it be great


    to have this for routing?

    View Slide

  19. The dream API
    const routes = {


    Users: "/users",


    User: "/users/:userId
    ?:
    invitationCode",


    Books: "/users/:userId/books",


    Book: "/users/:userId/books/:bookId",


    };

    View Slide

  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 } };

    View Slide

  21. How do we get there?

    View Slide

  22. TYPESCRIPT
    ZOONTEK

    View Slide

  23. brace yourselves

    View Slide

  24. Disclaimer:


    We're using types like functions

    View Slide

  25. Feature #1
    Template literal types

    View Slide

  26. Template literal types
    type User = {


    firstName: string;


    lastName: string;


    };


    type GetFullName =


    `${T["firstName"]} ${T["lastName"]}`;


    View Slide

  27. Template literal types
    type MichelSaison = GetFullName<{


    firstName: "Michel";


    lastName: "Saison";


    }>;
    "Michel Saison"
    The type is narrowed down to

    View Slide

  28. Feature #2
    Conditional types

    View Slide

  29. Conditional types
    type IsInvited =


    User extends "Manuel Valls"


    ? "No"


    : "Yes";

    View Slide

  30. Conditional types
    IsInvited<"Manuel Valls">;


    IsInvited<"Thomas Pesquet">;
    "No" 😌
    "Yes"
    Type:
    Type:

    View Slide

  31. Feature #3
    The `infer` keyword

    View Slide

  32. The `infer` keyword
    type RemoveHashtag =


    Input extends `#${infer Text}`


    ? Text


    : Input;
    Infer value after #

    View Slide

  33. The `infer` keyword
    RemoveHashtag<"#FollowFriday">;


    RemoveHashtag<"TopChef">;
    "FollowFriday"
    "TopChef"
    Type:
    Type:

    View Slide

  34. Feature #4
    Recursive type calls

    View Slide

  35. Recursive type calls
    type Person = {


    name: string;


    child
    ?:
    Person;


    };


    type GetFamilyNames = [


    T["name"],


    ...
    (T["child"] extends Person ? GetFamilyNames : []),


    ];
    Calls itself

    View Slide

  36. Recursive type calls
    type Atreides = GetDescendants<{


    name: "Paulus";


    child: {


    name: "Leto";


    child: {


    name: "Paul";


    };


    };


    }>;
    ["Paulus", "Leto", "Paul"]
    Type:

    View Slide

  37. Now that


    we have the tools

    View Slide

  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

    View Slide

  39. Let's extract the route types
    type ExtractRoute =


    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: "" };


    View Slide

  40. Let's extract the route types
    type ExtractRoute =


    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: "" };


    View Slide

  41. Let's extract the route types
    type ExtractRoute =


    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

    View Slide

  42. Let's extract the route types
    type ExtractRoute =


    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

    View Slide

  43. Let's extract the route types
    type ExtractRoute =


    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

    View Slide

  44. Let's extract the route types
    type ExtractRoute =


    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

    View Slide

  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">;

    View Slide

  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]


    : [T];


    Recursion
    Template literal type
    Infer
    Conditional type

    View Slide

  47. Splitting our URL segments


    (or embracing all the madness)
    Split<"/users/:userId", "/">


    Split<":invitationCode&:token", "&">
    ["", "users", ":userId"]
    [":invitationCode", ":token"]
    Type:
    Type:

    View Slide

  48. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};

    View Slide

  49. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    Use default generic


    to split the path

    View Slide

  50. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    Check if there's an item
    left in the split path

    View Slide

  51. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    Check if the segment is
    a path param


    (starting with ":")

    View Slide

  52. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    If so, a the param to
    our params object

    View Slide

  53. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    And continue with the
    rest of the path

    View Slide

  54. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    If it doesn't look like a
    param, continue with
    the rest of the path

    View Slide

  55. Building our params object
    type ExtractPathParams<


    Path extends string,


    Items = Split,


    > =


    Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}`


    ? { [_ in Name]: string } & ExtractPathParams


    : ExtractPathParams


    : {};
    If we reached the end
    of the split path, return
    an empty object

    View Slide

  56. Building our params object
    ExtractPathParams<"/users/:userId/books/:bookId">
    { userId: string; bookId: string }


    Type:

    View Slide

  57. Building our search object
    type ExtractSearchParams<


    Search extends string,


    Items = Split,


    > = Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}[]`


    ? { [_ in Name]
    ?:
    string[] } & ExtractSearchParams


    : Head extends `:${infer Name}`


    ? { [_ in Name]
    ?:
    string } & ExtractSearchParams


    : ExtractSearchParams


    : {};

    View Slide

  58. type ExtractSearchParams<


    Search extends string,


    Items = Split,


    > = Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}[]`


    ? { [_ in Name]
    ?:
    string[] } & ExtractSearchParams


    : Head extends `:${infer Name}`


    ? { [_ in Name]
    ?:
    string } & ExtractSearchParams


    : ExtractSearchParams


    : {};
    Building our search object
    More or less


    the same thing

    View Slide

  59. type ExtractSearchParams<


    Search extends string,


    Items = Split,


    > = Items extends [infer Head,
    ...
    infer Tail]


    ? Head extends `:${infer Name}[]`


    ? { [_ in Name]
    ?:
    string[] } & ExtractSearchParams


    : Head extends `:${infer Name}`


    ? { [_ in Name]
    ?:
    string } & ExtractSearchParams


    : ExtractSearchParams


    : {};
    Building our search object
    Only a special case for
    the :param[] syntax,


    indicating we expect an array
    "?type=cat&type=dog"


    { type: ["cat", "dog"] }

    View Slide

  60. Building our hash object
    type ExtractHashParams =


    Hash extends `:${infer Name}`


    ? { [_ in Name]
    ?:
    string }


    : {};


    View Slide

  61. Building our hash object
    type ExtractHashParams =


    Hash extends `:${infer Name}`


    ? { [_ in Name]
    ?:
    string }


    : {};


    I think you get the idea now

    View Slide

  62. Merging everything
    type ExtractRouteParams<


    Route extends string,


    ExtractedRoute extends RouteObject = ExtractRoute,


    > =


    ExtractPathParams &


    ExtractSearchParams &


    ExtractHashParams;


    View Slide

  63. Merging everything
    {


    userId: string,


    bookId: string,


    foo
    ?:
    string,


    bar
    ?:
    string[],


    baz
    ?:
    string,


    };
    ExtractRouteParams<"/users/:userId/books/:bookId
    ?:
    foo&:bar[]#:baz">;
    Type:

    View Slide

  64. And that's how you get


    a fully typesafe Router
    (don't do it in your app code)
    (I mean it)

    View Slide

  65. But thankfully we've made a
    library out of that!
    Demo time!

    View Slide

  66. Other features
    • Safe link creation


    • Keyboard focus reset ♿


    • Navigation blocking


    • Server-side rendering


    • …

    View Slide

  67. github.com/swan-io/chicane


    yarn add @swan-io/chicane

    View Slide

  68. View Slide

  69. Thank you!


    Questions? ✋
    Mathieu Acthernoene


    @zoontek
    Co-lead front-end developer at swan.io

    View Slide