❮ Table of contents

Building a type checked url router from scratch

This applies to TypeScript 4.1 and later.

The router, a central part in many web apps, is sometimes stringly typed. With inspiration from Dan Vanderkam's Twitter post, we'll examine how to build a url-based router where routes are type checked. Each route will be a pair of [component, template] where components are functions like in React, and templates are strings we can match against a url. We use TypeScript to make sure that templates account for all required properties necessary to instantiate its paired component.

Go straight to the demo

Let's start by creating a component called Page:

Based on PageProps, two properties are required to render this component; postId and commentId, while searchQuery is optional. Properties are declared as strings, and components are responsible for converting them to appropriate types. Url's to the component could be https://example.com/posts/10/comments/83 where query string would act as a series of key-value pairs for optional properties, like https://example.com/posts/10/comments/83?searchQuery=recipe. Ignoring optional properties, since they can't cause the app to fail in case they're missing, the template must match the required properties by Page, and to do so, we need a template such as posts/:postId/comments/:commentId where variables are prefixed with a colon and named according to the properties they match. Extracting variables from a template into a union is possible via template literal types:

Notice that InvalidTemplate has a :workerId variable, but it'll contain an error. To support prefixes and postfixes for variables, templates would need to be sorted. Say the following templates are defined, and the visitor goes to https://example.com/a/B1234

Template Template matches B1234?
a/:id Yes (id=B1234)
a/B:id Yes (id=1234)

Sorting the templates with colons last would be a step in the right direction, but it's far easier to just change the B-prefixed template to a/b/:id.

Next we'll extract required keys from PageProps, using a (slightly altered) type by TypeScript contributor Joe Calzaretta from Stack Overflow.

Knowing both required properties and what variables the template captures, it's possible to validate that the template is able to correctly instantiate its paired component.

That's strange - the template has insufficient variables for initializing Page since it's lacking userId, but TypeScript tells us it's valid. That's because A extends B means A is a subset of B and that's true, because the extracted variables "postId" | "commentId" is a subset of "postId" | "commentId" | "userId".

To fix this, we have to check that they both extend each other, eg. with the constraint A extends B, B extends A. If they're both a subset of each other, they must be equal. But writing that causes TypeScript to complain about a circular constraint, so we head to TypeScript's Github repo and copy Matt McCutchen's Equals type.

With Equals<X, Y>, RequiredKeys<T> and TemplateVariables<T>, we have the necessary types to define routes, or pairs of components and templates.

Try removing string extends U ? U : in the code above. TypeScript will complain about the potential error message in Template, because it doesn't extend string. It's unfortunate this code is necessary. Hopefully we'll be able to throw errors inside types one day.

Almost every single line written so far is removed by the compiler. It's time to get our hands dirty and write some good ol' JavaScript, sprinkled with a little TypeScript. Something that can turn a url into an object, objects that we'll pass to the component matching the url, and something that can do the reverse, turning an object into a url, a correct url that matches the template.

We could use lots of fancy types here, but let's instead build type safe abstractions on top of this later. First we'll integrate with History.

Next up is links.

I'd love to have a urlFor function where params became an optional argument if T have no required keys. But this can't be done according to Joe Calzaretta on Stack Overflow. Dan Vanderkam posted a solution!

Time to wrap it all up and create a demo application.

Click Compile to watch the router in action.

Demo

Bonus: Check for overlapping templates

Can't get enough type checking? Neither can I! We can check for overlapping templates such as :postId/:commentId and :postId/:relatedPostIds. If you have both templates, it's undefined which one is matched, since the url accessing them, eg. https://example.com/100/10 would fit both templates. Another example is templates without variables. Say you have two components and you by accident define the same template for both of them, then it would also be undefined which one is matched.

The trick is to first transform the templates into strings where variable names are replaced with the same name every time. So a template such as a/:x/:y is transformed into a/:var/:var. Should there be another component with a template such as a/:myVar/:myOtherVar, this would also be transformed into a/:var/:var, and now we know they overlap.

Before we continue, we need to investigate how union types deal with duplicates.

Now we have two keys in RouteKeys, and one in RouteUnion. RouteKeys will never get reduced to less keys than what's in the object, because objects do not allow for duplicate keys. If we had a way to count how many types there are in a union type, we could compare length(RouteUnion) with length(RouteKeys), and if they're not equal, it would mean that a template have been reduced in the union type due to being a duplicate, or in other terms, the template is overlapping another template.

Unfortunately we can't count types in a union, but with the help of a magic type, they can be turned into arrays, or tuples, and those we can count. I won't go through the type because I don't understand it, for me it's a black box where you put in a union type and get a tuple in return.

With a magic type at our disposal, we then define Routes<T> that turns unions into tuples and use the key ['length'] which returns the number of string literal types in the tuple, and fails with an error message in case number of keys in nonOverlappingTemplates doesn't equal number of transformed templates.

A minor annoyance here is that errors gets combined, so one invalid template will cause the type checker to mark all routes as invalid. Write me an email if you know a way around this.

One thing I've left out is a constraint on component props to be something like { [key: string]: string }. Without this, it's possible to declare any type for component props, causing a problem when initializing from a url, where all props are passed as string. Fixing this is left as an exercise to the reader.

That's it. I hope you enjoyed this article, and thank you for reading.