From ParisJS #99
Advanced TypeScript:How we made ourrouter typesafe
View Slide
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!
Context at Swandashboard web-banking onboardingconsent-appexplorer5 front-end applications
What's routing in a SPA?
What's routing in a SPA?1. The server sends the same HTML file2. The JS boots and reads the URL3. You display content based on that URL4. When navigating, JS "intercepts" events
Our journey of using routersparatron/hookrouterkyeotic/raviger?abandonedbreakingchanges
React Router?const App = ()=>(} />);
React Router?const App = ()=>(} />);Weak link
React Router?const Profile = ()=>{const { userId } = useParams();return (Books);};UntypableCan have any shapePossibly undefined
404my.app/users/undefined/books
Can we make routingtypesafe?
Union typesLet's have a quick look at
type Literal = string | number | boolean;Union types
Union typesconst 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 stringTS knows it's a numberTS knows it's a booleanIt's the only other possibility
type Cat = {tag: "cat";isVicious: boolean;};type Dog = {tag: "dog";isAGoodBoy: boolean;};type Animal = Cat | Dog;Union typesValue as typeThe type system can guardbased on that key
const fn = (animal: Animal)=>{if (animal.tag==="cat") {animal.isVicious;animal.isAGoodBoy;} else {animal.isVicious;animal.isAGoodBoy;}};Union typesErrorError
Wouldn't it be greatto have this for routing?
The dream APIconst routes = {Users: "/users",User: "/users/:userId?:invitationCode",Books: "/users/:userId/books",Book: "/users/:userId/books/:bookId",};
The dream typingtype Route =| { name: "Users", params: {} }| { name: "User", params: { userId: string, invitationCode?:string } }| { name: "Books", params: { userId: string } }| { name: "Book", params: { userId: string, bookId: string } };
How do we get there?
TYPESCRIPTZOONTEK
brace yourselves
Disclaimer:We're using types like functions
Feature #1Template literal types
Template literal typestype User = {firstName: string;lastName: string;};type GetFullName =`${T["firstName"]} ${T["lastName"]}`;
Template literal typestype MichelSaison = GetFullName<{firstName: "Michel";lastName: "Saison";}>;"Michel Saison"The type is narrowed down to
Feature #2Conditional types
Conditional typestype IsInvited =User extends "Manuel Valls"? "No": "Yes";
Conditional typesIsInvited<"Manuel Valls">;IsInvited<"Thomas Pesquet">;"No" 😌"Yes"Type:Type:
Feature #3The `infer` keyword
The `infer` keywordtype RemoveHashtag =Input extends `#${infer Text}`? Text: Input;Infer value after #
The `infer` keywordRemoveHashtag<"#FollowFriday">;RemoveHashtag<"TopChef">;"FollowFriday""TopChef"Type:Type:
Feature #4Recursive type calls
Recursive type callstype Person = {name: string;child?:Person;};type GetFamilyNames = [T["name"],...(T["child"] extends Person ? GetFamilyNames : []),];Calls itself
Recursive type callstype Atreides = GetDescendants<{name: "Paulus";child: {name: "Leto";child: {name: "Paul";};};}>;["Paulus", "Leto", "Paul"]Type:
Now thatwe have the tools
What a route can be"/users/zoontek""/users/zoontek?invitationCode=1234""/users/zoontek?invitationCode=1234#profile""/users/zoontek#profile"pathpath + searchpath + search + hashpath + hash
Let's extract the route typestype 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: "" };
Let's extract the route typestype 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
Let's extract the route typestype 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
Let's extract the route typestype 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
Let's extract the route typestype 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
Let's extract the route typesExtractRoute<"/users/:userId?:invitationCode">;{path: "/users/:userId";search: ":invitationCode";hash: "";}Type:{path: "/books/:bookId";search: "";hash: ":title";}Type:ExtractRoute<"/books/:bookId#:title">;
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];RecursionTemplate literal typeInferConditional type
Splitting our URL segments(or embracing all the madness)Split<"/users/:userId", "/">Split<":invitationCode&:token", "&">["", "users", ":userId"][":invitationCode", ":token"]Type:Type:
Building our params objecttype ExtractPathParams<Path extends string,Items = Split,> =Items extends [infer Head,...infer Tail]? Head extends `:${infer Name}`? { [_ in Name]: string } & ExtractPathParams: ExtractPathParams: {};
Building our params objecttype ExtractPathParams<Path extends string,Items = Split,> =Items extends [infer Head,...infer Tail]? Head extends `:${infer Name}`? { [_ in Name]: string } & ExtractPathParams: ExtractPathParams: {};Use default genericto split the path
Building our params objecttype 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 itemleft in the split path
Building our params objecttype 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 isa path param(starting with ":")
Building our params objecttype 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 toour params object
Building our params objecttype ExtractPathParams<Path extends string,Items = Split,> =Items extends [infer Head,...infer Tail]? Head extends `:${infer Name}`? { [_ in Name]: string } & ExtractPathParams: ExtractPathParams: {};And continue with therest of the path
Building our params objecttype 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 aparam, continue withthe rest of the path
Building our params objecttype 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 endof the split path, returnan empty object
Building our params objectExtractPathParams<"/users/:userId/books/:bookId">{ userId: string; bookId: string }Type:
Building our search objecttype 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: {};
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 objectMore or lessthe same thing
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 objectOnly a special case forthe :param[] syntax,indicating we expect an array"?type=cat&type=dog"{ type: ["cat", "dog"] }
Building our hash objecttype ExtractHashParams =Hash extends `:${infer Name}`? { [_ in Name]?:string }: {};
Building our hash objecttype ExtractHashParams =Hash extends `:${infer Name}`? { [_ in Name]?:string }: {};I think you get the idea now
Merging everythingtype ExtractRouteParams<Route extends string,ExtractedRoute extends RouteObject = ExtractRoute,> =ExtractPathParams &ExtractSearchParams &ExtractHashParams;
Merging everything{userId: string,bookId: string,foo?:string,bar?:string[],baz?:string,};ExtractRouteParams<"/users/:userId/books/:bookId?:foo&:bar[]#:baz">;Type:
And that's how you geta fully typesafe Router(don't do it in your app code)(I mean it)
But thankfully we've made alibrary out of that!Demo time!
Other features• Safe link creation• Keyboard focus reset ♿• Navigation blocking• Server-side rendering• …
github.com/swan-io/chicaneyarn add @swan-io/chicane
Thank you!Questions? ✋Mathieu Acthernoene@zoontekCo-lead front-end developer at swan.io