Typed Routes with TypeScript

Reposted from the Esper Tech Blog.

Although Esper started out as an OCaml shop, we also use quite a bit of TypeScript to build our front-end. “Why We Use TypeScript” would probably be a long post in and of itself, but this post is about how we use TypeScript to implement a small, open-source library that implements statically-typed route checking.

If you don’t care about the why or how, you can check out said library on Github or download the typed-routes package on NPM.

Routing

By routing, we mean taking some action based on a file-like string path. The path in question is usually a URL, and routing can occur on a web app backend, by the frontend for a single page app, or even by a mobile app.

Probably the most popular way of representing routes in JavaScript-land is an Express-style path (which is typically implemented using the path-to-regexp package). An Express-style path looks like this: /path/to/:arg1/:arg2. It matches /path/to/abc/123, and it captures the third and fourth parts of that path inside a param object that looks like: { arg1: "abc", arg2: "123" }.

This works well enough, but it’s not ideal from a static type-checking perspective. In TypeScript at least, /path/to/:arg1/:arg2 is just a string. There isn’t much it can (currently) do to parse out the parameters. Nor is there much it can do to verify that arg2 is an integer.

Chainable Route Definitions

But there’s nothing saying we have to use Express-style paths. Or even (just) strings at all. That’s where typed-routes comes in. With typed-routes, you can write your route with a chainable interface like this:

import { default as createRoute, IntParam } from "typed-routes";

let route = createRoute()
  .extend("path", "to")
  .param("arg1")
  .param("arg2", IntParm);

You can then convert string paths to param objects like this:

route.match("/path/to/abc/123"); // => { arg1: "abc", arg2: 123 }
route.match("/something/else");  // => undefined

And you convert param objects back to string paths like this:

route.from({ arg1: "abc", arg2: 123 }); // => "/path/to/abc/123"
route.from({ arg1: 123, arg2: "abc" }); // => Type Error

The library also features optional parameters and wildcard / rest routes as well:

let route = createRoute()
  .param("arg1")
  .opt("arg2")
  .rest("args", IntParam);

route.match("/abc/xyz/123/456");
  // => { arg1: "abc", arg2: "xyz", args: [123, 456] }
route.match("/abc");
  // => { arg1: "abc", args: [] }

Type All the Things

The actual implementation isn’t anything fancy, but the typing takes advantage of mapped types, a relevatively recent addition to TypeScript.

Here’s what the type for our param method looks like. For the sake of this post, these aren’t the exact types being used by typed-routes but they help illustrate what’s going on.

interface Route<P = {}> {
  ...
  param<K extends string, T = string>(
    key: K,
    type?: ParamType<T>
  ): Route<P & { [X in K]: T }>;
  ...
}

A few explanations:

Our Route interface is defined with a generic type describing how the parameters passed to this type look like. So Route<{ arg1: string }> means “a route with a single string param named arg1”.

K is a generic type indicating the key we want to use to describe a given parameter. TypeScript supports string literal types, so it’s capable of recognizing that K is of type "arg1" or "myKey" or whatever is passed to the function.

T is a generic type indicating what the type of that parameter should be. It defaults to string is not provided or inferred. ParamType is another interface for an object that describes how to convert a string path part to another type and back again. It looks like this:

export interface ParamType<T> {
  parse(s: string): T|undefined;
  stringify(t: T): string;
}

typed-routes provides a few basic types such as IntParam. There isn’t an integer type in JavaScript though, so IntParam really has a signature of ParamType<number>, although its implementation code ensures that the parsed value is always an integer.

So when we call param("arg1", IntParam), TypeScript infers K to be "arg1" and T to be of type number.

The last bit to explain is the return value: Route<P & { [X in K]: T }>. This is where mapped types comes in. [X in K] assumes that K is a union of string literals like "key1"|"key2"|"key3" and maps each string literal to some value X. Combined with the keyof operator, this lets us create new types using the keys of other types:

type Nullable<S> = {
  [X in keyof S]: S[X]|null;
};

let val: Nullable<{ x: number, y: string }> = {
  x: 123, // Has type number|null
  y: null // Has type string|null
}

In the immediate case though, we know that K is just a single string literal that we want to use as the name of a param with type T. That is, if K refers to "arg1" and T referes to number, then { [X in K]: T} means { arg1: number }.

We use the intersection operator (&) to unite the previous param type P with the new addition. Taken altogether, having a return type of Route<P & { [X in K]: T }> means that TypeScript understands the param method to return a new Route some param K referring to type T added to the original types. We can then chain these calls together and create arbitrarily long routes with whatever types we want.

We do something similar to implement optional types and rest types.

interface RestRoute<P = {}> { ... }

interface OptRoute<P = {}> extends RestRoute<P> {
  ...
  opt<K extends string, T = string>(
    key: K,
    type?: ParamType<T>
  ): OptRoute<P & { [X in K]?: T }>;

  rest<K extends string, T = string>(
    name: K,
    type?: ParamType<T>
  ): RestRoute<P & { [X in K]: T[] }>;
  ...
}

interface Route<P = {}> extends OptRoute<P> {
  ...
  param(...); // Same as before
  ...
}

Note that we extend Route from OptRoute and OptRoute from RestRoute because want TypeScript to understand that a required parameter cannot follow an optional param and that no params can follow a rest / remainder param.

Not All Batteries Included

typed-routes only implements parsing a string path to get a typed params object and stringifying the params object back to path. It doesn’t actually implement routing (i.e. doing something based on a path) itself. It’s more of an alternate to path-to-regexp than anthing else.

But an actual router implementation itself isn’t too tricky. Here’s what a simple hashchange-based router for an SPA might look like:

import { createRoute, RestRoute, IntParam } from "./index";
import {
  showHome,
  showAllGroups,
  showGroupProfile,
  showGroupMembers,
  showMemberProfile,
  showNotFound
} from "./my-pages";

// Routes
const conf = { prefix: "#!/" };
const allGroups = createRoute(conf).extend("groups");
const groupProfile = allGroups.param("groupId");
const groupMembers = groupProfile.extend("members").opt("page", IntParam);
const memberProfile = groupProfile.extend("member").param("userId");

// Combine route with callback to run if matched
const onRoute = <P>(
  route: RestRoute<P>,
  action: (p: P) => void
) => (path: string) => {
  let params = route.match(path);
  if (params) {
    action(params);
    return true;
  }
  return false;
};

window.onhashchange = function() {
  let { hash } = location;

  // Handle home page
  if ("#!/".startsWith(hash)) {
    showHome();
    return;
  }

  // Find first match
  let match = [
    onRoute(allGroups, showAllGroups),
    onRoute(groupProfile, ({ groupId }) => showGroupProfile(groupId)),
    onRoute(groupMembers, ({ groupId, page }) => showGroupMembers(
      groupId,
      page
    )),
    onRoute(memberProfile, ({ groupId, userId }) => showMemberProfile(
      groupId,
      userId
    ))
  ].find((match) => match(hash));

  // No routes matched, show 404-like page
  if (! match) {
    showNotFound();
  }
};

Trade Offs

When you build a brand new app with TypeScript, it’s very tempting to try to type-check everything. But with web apps at least, you generally have two things working against you: (1) TypeScript is a superset of JavaScript, which comes with a lot of things that are not statically typed, and (2) you’re dealing with user input and/or distributed systems, where at least some of those systems are not things you control.

As such, we’ve occasionally found ourselves bending over backwards to write our code in a way that makes it easier to catch errors with TypeScript, as opposed to simply writing idiomatic JavaScript. This generally takes the form of wrapper functions or objects that have no real purpose from JavaScript’s perspective, but allow us to pass along additional type information. It’s fair to say we might have done that with typed-routes, insofar that it’s a bit more verbose and not as performant as simply defining a bunch of regular expressions.

In Esper’s case though, we encode a quite a bit of information in the routes and paths we pass around our single page app. That information is visible (and easily modifiable) via the URL. The approach outlined here allows us to be reasonably confident that we’re not dealing with malformed data when pulling params from our URL, and that’s a win for us.

Thanks for reading. We hope typed-routes is useful for you. PRs and feature requests are welcome.

Comments