Validating routes with TypeScript

Validating routes with TypeScript

We're always looking for ways to make code changes safer. At Uplift, we lean on TypeScript to keep projects maintainable, and push as many problems to the type layer as possible. We have built helpers to ensure that our form fields all have labels, that our React Native push notifications are fully covered and that the data payloads match what the server is sending, as well as many others. Getting a compilation failure for these means that we can't push code until it's fixed. As TypeScript matures, we're able to lift more of these potential bugs from run-time to build-time.

Even something as small as a URL change can break a site in a myriad of ways. Providing reliable applications to our customers is important, and during development feedback can mean lots of small changes - including the URL structure. A change from /users/:userId to /teams/:teamId/users/:userId could mean that many links would break at runtime and we'd only know we missed one when a deluge of 404s arrives. We use a custom helper function called makeUrl – its job is to take in a URL constant, replace any tokens contained within, and append a querystring.

makeUrl("/users/:userId", { userId: user.id });
// /users/1234

makeUrl("/:organizationSlug/teams/:teamId", { organizationSlug: "my-organization", teamId: 1234 })
// /my-organization/teams/1234

makeUrl("/search/:repoSlug", { repoSlug: "my-repo" }, { term: "userId" })
// /search/my-repo?term=userId

This has been super helpful for us, and makeUrl takes care of stringifying, removing falsey values so we don't get ?term=null on accident, and we can even control trailing slashes in a few ways if needed.

 makeUrl("/home", null, null, { trailingSlash: "ensure" })
 // /home/

However, one thing that it didn't help with was telling us about the Url. Did we provide all the tokens? Are the tokens we provided correct and without typos? 🤷‍♂️ With a recent refactor, we dramatically changed the shape of our URLs, and with that we needed to ensure we got all the tokens updated properly. I had previously written a type-safe I18N helper that would validate that the nested/namespaced I18N key was in our i18n.json file, so I was pretty sure I could bring type-safety to our URLs.

Enter Template Literal Types

TypeScript introduced Template Literal Types back in 4.1. This new type allows for some extraordinary use-cases. In my i18n function, I had used this (and some Stack Overflow help) to build out unions of all the recursive leafs of the json, as well as just the leafs that contained a child object. This let me validate the namespaces as well as the complete translation key.

Cool, but what does it look like? Let's imagine we want to type check some CSS classnames for a component. In this imaginary component, we already have some classnames that are a combination of size and color. Previously, we might check these at run-time, maybe even using an invariant or a block that is removed in production. Here's our props,

type size = "sm" | "lg";
type color = "primary" | "destructive";
    
type ButtonProps = {
    className?: string;
}

Ideally, our className wouldn't just be a string, but could enforce that the className is only something we know about. (Ideally, we would use size/color as separate props and combine in our component, but stick with me 🙃). With TLT (I'm going to abbreviate this now), we can update our types,

type SizeColorClassName = `${size}-${color}`;
// this expands to a Union of,
// "sm-primary" | "lg-primary" | "sm-destructive" | "lg-destructive"
  
type ButtonProps = {
    className?: SizeColorClassName;
}
  
// now, we get a type error here,
<Button className="sm-green" />

Whoa, that's really helpful! Now we get told by the compiler when we make a mistake instead of finding out after deploy that one of our buttons isn't styled. TLT also comes with some utility types, like Lowercase,

type color = "GREEN" | "BLUE";
type ColorKey = Lowercase<color>;
// "green" | "blue"

The ability to transform strings at the type level is powerful, especially when you consider that you can import objects, arrays, etc from JS, assert that they are constant (as const) and you get access to the string.

const theme = {
    colors: {
        DESTRUCTIVE: "#aa0000",
        PRIMARY: "#5555ff",
    },
    sizes: {
        SM: "1rem",
        LG: "3rem",        
    }
} as const;
    
type color = Lowercase<keyof typeof theme["colors"]>
type size = Lowercase<keyof typeof theme["sizes"]>
type SizeColorClassName = `${size}-${color}`;
// "sm-primary" | "lg-primary" | "sm-destructive" | "lg-destructive"

Cool! So, now we know how to use TLT for transforming known, given types. But, how do we parse strings with TypeScript?

infer

This is probably my most visited TypeScript doc page. I use infer just enough to know where/when I want to use it, but inevitably I'll forget that it has to be part of a conditional type. Pairing infer with TLT, we can parse a string type,

type ValidEvent = "blur" | "focus" | "submit";

type GetEventKey <T extends string> = T extends `on${infer EventKey}`
    ? Lowercase<EventKey> 
    : T extends ValidEvent
        ? T
        : never;

GetEventKey<"onBlur">;
// "blur"
    
GetEventKey<"blur">;
// "blur"
    
GetEventKey<"click">;
// never

Let's take this step-by-step, focusing on the important bits,

type GetEventKey <T extends string> = T extends `on${infer EventKey}` …

Here, we're looking to see if the given type T matches a pattern. In our case, we are checking to see if T starts with on, if it does then we extract the remainder of the string into the new type variable EventKey. If it matches, then we return the lowercased version of EventKey as our new type.

type GetEventKey <T extends string> = T extends `on${infer EventKey}`
    ? Lowercase<EventKey>

If it doesn't match the pattern, then we check to see if T overlaps with ValidEvent, returning T directly if its a ValidEvent or never if it's not.

: T extends ValidEvent
    ? T
    : never;

In our example, we could allow an invalid EventType through as long as it starts with on, let's fix that,

type GetEventKey<T extends string> = T extends `on${infer EventKey}`
  ? Lowercase<EventKey> extends ValidEvent
    ? Lowercase<EventKey>
    : never
  : T extends ValidEvent
  ? T
  : never

GetEventKey<"click">
// never

GetEventKey<"onClick">
// never. yay!

Now we ensure that the lowercased version of EventKey is a ValidEvent too.

Ok, but what about URLs?

Alright, now that we see how to parse strings with TypeScript using pattern matching we can explore something more complicated. In our tokenized URLs, /:organizationSlug/users/:userId, we might have many tokens that need to be parsed out. We can do this with a recursive type,

type UrlTokens<T extends string> = T extends `${infer Root}/:${infer Token}/${infer Rest}`
  ? Token | UrlTokens<Rest>
  : T extends `:${infer Token}/${infer Rest}`
  ? Token | UrlTokens<Rest>
  : T extends `${infer Root}/:${infer Token}`
  ? Token
  : T extends `:${infer Token}/`
  ? Token
  : T extends `:${infer Token}`
  ? Token
  : never;
  
 UrlTokens<"https://uplift.ltd/:organizationSlug/users/:userId">
 // "organizationSlug" | "userId"

It's a lot, we'll work through it in pieces,

type UrlTokens<T extends string> = T extends `${infer Root}/:${infer Token}/${infer Rest}`

Here we're checking to see if our string matches this pattern, starting with something, then /:, some token text, followed by a / and something else. If this matches, we grab the Token and pass the Rest back into UrlTokens for parsing. We dismiss Root because it won't have any tokens, the first encountered token will be grabbed by the Token type var. At this point, with our example URL it works out as,

type UrlTokens<T extends string> = T extends `${infer Root}/:${infer Token}/${infer Rest}` 
  ? Token | UrlTokens<Rest>
    
UrlTokens<"https://uplift.ltd/:organizationSlug/users/:userId">
    
// Root: "https://uplift.ltd"
// Token: "organizationSlug"
// Rest: "users/:userId"

Now we have "organizationSlug" | UrlTokens<"users/:userId"> which gets handled a few lines later,

: T extends `${infer Root}/:${infer Token}`
  ? Token
    
// Root: "users"
// Token: "userId"

And we're left with "organizationSlug" | "userId". The other branches are there for handling trailing slashes and completeness.

Now let's transform our union into an object with all our tokens as the keys,

type UrlTokensMap = Record<UrlTokens<"/:organizationSlug/users/:userId">, number | string>
// { organizationSlug: number | string, userId: number | string }

Applying this to our makeUrl is straightforward now,

declare function makeUrl<
  Url extends string,
  Token extends string = UrlTokens<Url>
>(
  url: Url,
  tokens?: Record<Token, number | string>
): string;
    
makeUrl("/:organizationSlug", { organizationSlug: "my-slug" });

Now that the second parameter is typed, you get autocompletion in your editor, errors if you're missing a token, or errors for providing tokens that aren't in the URL.

We can be stricter

Above we've made tokens optional because we might have a URL without tokens, but we aren't enforcing that URLs with tokens are properly handled. To do so, we'll need to add a conditional type for makeUrl. Ideally we would ensure that no tokens are passed for URLs that don't have tokens, and we would require complete coverage of URLs that do have tokens. First, I'll introduce you to another type that we have. This type takes care of parsing a URL for it's tokens and transforming it into our TokensMap type. If there are no tokens, then this type returns never.

type UrlTokensMap<Url extends string, K extends string = UrlTokens<Url>> = [K] extends [never]
  ? never
  : [K] extends [string]
  ? { [P in K]: string | number }
  : never;

There's some extra syntax involved ([K] extends [never]) to work around the distributive property of conditional types. I won't cover that here, just know that it's to properly match our types. Here's what we get with this new type,

UrlTokensMap<"/:organizationSlug">
// { organizationSlug: number | string }
    
UrlTokensMap<"https://uplift.ltd">
// never

Because we want to have different arguments based on whether we have tokens or not, we'll use a tuple for our arguments, switched on whether there are tokens or not.

declare function makeUrl<
  Url extends string,
  Token = UrlTokens<Url>
>(
  url: Url,
  ...args: Token extends never ? [(null | undefined | never)?] : [UrlTokensMap<Url>]
): string;

In this example, the second argument becomes optional when there are no tokens, but we enforce that no value is actually passed. {}, null, or undefined are allowed but not required. With tokens, however, we require our TokensMap type which itself requires all tokens to be provided. In use,

makeUrl("/:organizationSlug/users/:userId");
// Type Error! Expects 2 arguments, 1 provided
    
makeUrl("/:organizationSlug/users/:userId", {userId: 1234 });
// Type Error! Property "organizationSlug" is missing in type { userId: 1234 }

makeUrl("/home", {userId: 1234 });
// Type Error! { userId: 1234 } is not assignable to never

So that's how you can lift some runtime error checking into the type system to hopefully save yourself some headaches down the road. Play with the code, let me know what you think. I have ideas for letting routes export their own querystring param types which we could lift into this function for type checking of the querystring too - there is so much potential here.