Building a type-safe router in TypeScript
February 16, 2026Recently, I’ve been relieved to see that type-safe routing is becoming a bit of a trend in the TypeScript ecosystem1. It’s long been a pet peeve of mine that state-of-the-art web app routing libraries did not offer a way to ensure that the links you render in your application are actually valid paths — paths that your application will recognize and be able to correctly display content for.
I really mean that: I’ve been lowkey grumpy about this for years.
So much so that, a couple years ago, I spent a few days over Christmas holiday and proved out my own concept for a typesafe router. I then sat on it until very recently, because, well, I have a family with young kids and a full-time job and an addiction to riding and maintaining bicycles. It just hasn’t been a priority because I’ve no delusions of rivaling the popular react-router or tanstack router, even if I had the time.
Despite that, I think there are some interesting and novel aspects of my router, and that it represents some nice details in how to design an API and effectively wield TypeScript to design nice APIs. So I’m finally going to take the time to write about it.
Finding the Inspiration
I’ve spent a bunch of time writing Lisp (particularly, Clojure), and one of the most important lessons I learned from that time is, roughly:
Functions2 are one of the most foundational abstractions we have and the most powerful tool we have for building abstractions and composing systems and behaviors. When you embrace functions as the foundation of your API design and as its tool for composition, the result is often somehow both simpler and more powerful. Often more accessible, too.
With that in mind, I found inspiration for my router while I was completing my annual tradition of solving the first couple days of Advent of Code before bowing out once I remember that December is far too busy for me to have any time to spare.
This time, I was using Rust. And I decided I needed a parser, but I didn’t (yet) want to learn how to write a parser. And, for aforementioned things related to functions, the word “combinator” alone is a kind of catnip to me. So, I found myself using nom, a parser combinator library.
In TypeScript syntax, a parser in nom is a function that would look roughly like:
type Parser<T> = (input: string) => ParseResult<T>;
type ParseResult<T> = ParseSuccess<T> | ParseError;
type ParseSuccess<T> = { success: true, data: T, remaining: string };
type ParseError = { success: false, error: Error };
(I took a lot of liberty translating this from Rust, but, you get the idea).
So, you might have a “parser” that just matches a valid base-10 ascii string of digits and produces a ParseResult<number>. Or one that expects to find a literal keyword at the beginning of the input string. Either way, the parser is just a function. If it succeeds, you get the extracted data and a new string with the remaining input that the parser didn’t consume. Otherwise, an error.
By itself this might seem uninteresting, and it probably is, except that this model allows for the creation of sophisticated parsers by the combining of smaller, simpler parsers. To do that, you pass the simpler parser functions into other functions that will return you the new, composed parser function. And those functions are called parser combinators.
To complete your indulgence in this diversion, consider this example from our made-up TypeScript port of nom:
const parseNumberArray = delimited(
symbol("["),
separatedList(parseNumber, symbol(",")),
symbol("]"),
);
// and now parseNumberArray is a function of type
// `(input: string) => ParseResult<number[]>`
Starting with a concrete parser that will match and parse a numeric string into a number, we used the separatedList combinator to create a new parser that will match a bunch of numbers, so long as they are separated by a comma. Then we derived our final parser using the delimited combinator: it’ll match the list of numbers as long as it’s prefixed by a left bracket and suffixed by a right bracket.
The really cool thing here, I think, is how function composition becomes the basis for a declarative language for building arbitrarily complex parsers. But you didn’t need to learn a new language. You’re just using the functions and syntax of the language you already know.
Forming the Concept
OK, you’re still reading, so … thanks for bearing with that tangent.
Anyhow. While learning to use nom, I had a thought: I could model specific routes in web application as parsing functions. Want to know if the current URL is the “user profile” route? Call its parser:
const result = parseUserProfile(window.location.pathname);
if (result.success) {
// User profile matched! And data has the userId:
console.log("user profile:", result.data.userId);
}
This has some cool properties. For example, you could now actually parse data out of the URL. If one of the path segments was an integer of milliseconds since the epoch, your parser could give you a Date object.
However, the problem that I actually want to solve here is actually going the opposite direction. I want to be able to semantically say: hey, please construct a URL for the user profile route; here is the user ID and please flag a type error if I forgot to give you any data you need.
So, I wondered: could I instead model a route as an isomorphism3 between an arbitrary path string and the data that can be parsed from it?
type Path<Data> = {
match: (input: string) => ParseResult<Data>;
make: (data: Data) => string;
}
This would be great, because then we could build values that, at runtime, could be used to match and parse data from the URL path and also be able to generate links. We could centrally define the set of routes that our application is composed of, as individual values, and then weave these values throughout our codebase. They would serve as the glue that keeps the whole application in sync with itself, preventing mistakes like typos and unfinished refactors. Maybe things could end up looking like:
<a href={ViewMessagePath.make({ messageId: "some-id" })}>...</a>
// elsewhere:
const match = useMatch(ViewMessagePath);
Really, at this point, I saw one problem that stood in the way of this being a good idea.
Problem: Readability Matters
As much as I dislike the status quo, where we build string constants to represent a path, and then (indirectly) rely on path-to-regexp to turn them into something actually useful, I have to admit that it’s generally4 pretty great when it comes to readability.
app.route("/users/:id", () => ...);
app.route("/users/:id/edit", () => ...);
In effect, WYSIWYG.
Contrast to the idea I’ve been percolating, which might look something like:
app.route(UserByIdPath, ({ userId }) => ...);
app.route(EditUserByIdPath, ({ userId }) => ...);
It’s not immediately obvious what these paths actually represent. In the context of a broader application, it’s likely that you’ll end up being concerned with dozens of paths and that their definitions may relate to each other. In that case, you could find yourself jumping through multiple layers of definitions in order to mentally build a reified understanding of what the URL string might concretely look like. Not good.
Sure, you could write JSDoc comments for each path that contain some more human-readable representation, and those will show up in your editor. It’d require you to manually keep those comments up to date and duplicate your efforts during definition.
Honestly, this is probably the weakest point of the model I’m constructing. My best idea was to use TypeScript’s Template Literals to construct and hoist a human-readable representation into the type system.
type Path<P extends string, Data> = {
path: P;
match: (input: string) => MatchResult<Data>;
make: (data: Data) => string;
}
Now, wherever you see a Path in your codebase, you can hover your mouse cursor over it, and your editor will show you:
const EditUserByIdPath: Path<"/users/:userId/edit", {
userId: string
}>
If you’re a fan of editor inlay hints, perhaps the readability experience is almost as good?
But, I’ll concede: it’s a trade-off. Modeling paths in this way costs some small level of readability in exchange for enhanced type-safety across the application.
Defining the language
So, how feasible is it to implement? Well, you’re reading a post about it, so, it’s probably no surprise to hear that it wasn’t actually that hard. This post is already much longer than I had anticipated, so, instead of discussing how I built it, I’m going to briefly describe how this language works before moving on to closing thoughts. But don’t worry, there will be links to the source for the curious.
Let’s take a look:
const ServicesPath = path("services");
const ServicePath = path(ServicesPath, string("serviceId"));
const ServiceVersionPath = path(ServicePath, "version", number("version"));
Here, the type of ServiceVersionPath will be inferred to be:
Path<"/services/:serviceId/version/:version[number]", {
serviceId: string;
version: number;
}>
Most of the time, paths are pretty simple: just static text and maybe a string parameter or two. So we have the path() combinator to build them from hard-coded text and from other paths. Then there’s string()5 and number() to make paths that match a single path segment and extract its data.
Speaking of segments, there’s the segment() combinator that handles segmentation logic — it consumes a path segment and yields a successful match only when its inner path matches against the entirety of the segment. It’s built atop matchRegexp() which … matches … regexps. There’s also keyAs(), that transforms the data yielded by a path by placing it in an object under a given key. It’s built atop mapData(), which lets you arbitrary transform the data yielded by a path (or decide you didn’t want its inner path to be considered a match at all).
These combinators all snap together elegantly. Just consider the definition for the string() path:
export const matchString: Path<StringTypeIndicator, string> = matchRegexp({
regexp: /^(.+)($)/,
});
export const string = <K extends string>(key: K) =>
segment(keyAs(key, matchString));
I love this because 90% of the time, this language is solving a very simple problem space. For that 90% of the time, you get a very simple and straightforward language for defining your paths. But, when you need to, its layers peel away and you can accomplish whatever craziness you might want.
Want a path segment that only matches UUIDs? Or that matches ISO8601 dates and parses them into Date objects (and, vice versa, when generating links)? Or one that matches a number if and only if it’s in the fibonacci sequence? Sure, you can do that, easy.
What about the router?
Wait, it’s the end of the post and I never talked about routing? Well, I did build that too, but, I don’t think it’s the interesting part of all this.
Or, perhaps it is the interesting part in that it’s not the interesting part.
Paths contain the functionality to match against an input string, so, a router need only delegate to the paths it’s considering and then make a decision about which path should “win” if there are multiple matches. Admittedly, I went a bit overboard here by designing these paths so that they keep track of the arity of dynamic data they parse, so you’ve got multiple options for deciding how to do that.
Out of the box, when comparing these two paths:
const UserPath = path("users", string("userId"));
const UsersListPath = path("users/list");
Given the input "/users/list", it’ll favor UsersListPath, regardless of the order you provide the two paths. That’s because it favors the path that captures fewer dynamic data.
Final thoughts
There are a couple ways it could be improved.
One is that, when matching, paths may end up performing duplicate work: particularly if the paths under consideration build upon each other. A caching layer could probably be introduced to help with this6. Another crazy idea I have is to build a compiler that recognizes these path constructions and emits more optimized code.
Another area it could be improved relates to search params. Sometimes (not always), search params make sense to be part of the “public interface” of a path. I’m certain this could be solved, but, there is some trickiness to consider as most of the conveniently-named combinators (such as string()) operate on path segments.
However, I’m really pleased with how elegantly this system builds upon itself. As a result of that:
- The code is relatively tiny (just a few KBs!)
- The React specific layer is pretty small and uninteresting, as it relies on the core definitions for most of its work
- It’s extremely flexible despite being very accessible
- It tree shakes well, so, there’s no penalty for just using the path DSL and ignoring the parts you don’t need
Additionally, it’s decently fast in my testing. I wouldn’t hesitate to use it in a real web app on the grounds of performance.
This was a fun experiment for me, and I hope also an interesting example in API design.
Footnotes
-
At least, I’ve seen a few pop up on reddit; most recently werkbank and waymark. ↩
-
Closures. I mean closures. ↩
-
OK, not truly an isomorphism, because the parsing step can fail. But, you get the idea. ↩
-
Once you try to add in arbitrary regexps, or more sophisticated matchers, I think both readability and write-ability fall apart. ↩
-
That’s right, you can have functions whose names conflict with TypeScript types. It’s obvious when you think about it: types don’t exist at runtime so it’s natural that they get their own namespace. I have made somewhat of a habit of leveraging this when building DSLs. ↩
-
It’d be convenient if JavaScript had optional dynamic binding. Or, maybe I’ve just been brain-damaged by too much exposure to Lisp. ↩