Slide 1

Slide 1 text

Advanced TypeScript: How we made our router typesafe

Slide 2

Slide 2 text

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!

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

What's routing in a SPA?

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

React Router? const App = () => ( } /> );

Slide 8

Slide 8 text

React Router? const App = () => ( } /> ); Weak link

Slide 9

Slide 9 text

React Router? const Profile = () => { const { userId } = useParams(); return ( Books ); }; Untypable Can have any shape Possibly undefined

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

Can we make routing typesafe?

Slide 12

Slide 12 text

Union types Let's have a quick look at

Slide 13

Slide 13 text

type Literal = string | number | boolean; Union types

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

const fn = (animal: Animal) => { if (animal.tag === "cat") { animal.isVicious; animal.isAGoodBoy; } else { animal.isVicious; animal.isAGoodBoy; } }; Union types Error Error

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

The dream API const routes = { Users: "/users", User: "/users/:userId ?: invitationCode", Books: "/users/:userId/books", Book: "/users/:userId/books/:bookId", };

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

How do we get there?

Slide 22

Slide 22 text

TYPESCRIPT ZOONTEK

Slide 23

Slide 23 text

brace yourselves

Slide 24

Slide 24 text

Disclaimer: We're using types like functions

Slide 25

Slide 25 text

Feature #1 Template literal types

Slide 26

Slide 26 text

Template literal types type User = { firstName: string; lastName: string; }; type GetFullName = `${T["firstName"]} ${T["lastName"]}`;

Slide 27

Slide 27 text

Template literal types type MichelSaison = GetFullName<{ firstName: "Michel"; lastName: "Saison"; }>; "Michel Saison" The type is narrowed down to

Slide 28

Slide 28 text

Feature #2 Conditional types

Slide 29

Slide 29 text

Conditional types type IsInvited = User extends "Manuel Valls" ? "No" : "Yes";

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

Feature #3 The `infer` keyword

Slide 32

Slide 32 text

The `infer` keyword type RemoveHashtag = Input extends `#${infer Text}` ? Text : Input; Infer value after #

Slide 33

Slide 33 text

The `infer` keyword RemoveHashtag<"#FollowFriday">; RemoveHashtag<"TopChef">; "FollowFriday" "TopChef" Type: Type:

Slide 34

Slide 34 text

Feature #4 Recursive type calls

Slide 35

Slide 35 text

Recursive type calls type Person = { name: string; child ?: Person; }; type GetFamilyNames = [ T["name"], ... (T["child"] extends Person ? GetFamilyNames : []), ]; Calls itself

Slide 36

Slide 36 text

Recursive type calls type Atreides = GetDescendants<{ name: "Paulus"; child: { name: "Leto"; child: { name: "Paul"; }; }; }>; ["Paulus", "Leto", "Paul"] Type:

Slide 37

Slide 37 text

Now that we have the tools

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

Splitting our URL segments (or embracing all the madness) Split<"/users/:userId", "/"> Split<":invitationCode&:token", "&"> ["", "users", ":userId"] [":invitationCode", ":token"] Type: Type:

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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 ":")

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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"] }

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

Building our hash object type ExtractHashParams = Hash extends `:${infer Name}` ? { [_ in Name] ?: string } : {}; I think you get the idea now

Slide 62

Slide 62 text

Merging everything type ExtractRouteParams< Route extends string, ExtractedRoute extends RouteObject = ExtractRoute, > = ExtractPathParams & ExtractSearchParams & ExtractHashParams;

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

And that's how you get a fully typesafe Router (don't do it in your app code) (I mean it)

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

Other features • Safe link creation • Keyboard focus reset ♿ • Navigation blocking • Server-side rendering • …

Slide 67

Slide 67 text

github.com/swan-io/chicane yarn add @swan-io/chicane

Slide 68

Slide 68 text

No content

Slide 69

Slide 69 text

Thank you! Questions? ✋ Mathieu Acthernoene @zoontek Co-lead front-end developer at swan.io