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.
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.
Done, logged to console.
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.
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.