TypeScript String Theory

TypeScript brings power to even the humble string. Let's take a look at several ways we incorporate better string-based types into our code.

String Constant Types

Let's start simple. You already know about the string type, but did you know you could assign a specific string value as a type?

type Notification = {
    type: "email";
    content: string;
}

In our Notification type, the content can be any string value, but the type must always be "email". What's the point? Why would you do this?

For two reasons. The domain-related reason could be that you only support email notifications right now, but you want to add support for SMS later. You can ensure that, for now, your codebase can only have Notifications that are of type email. You might be tempted to leave the type field off entirely if there's only only possible value, but having a type field makes Notification open to extension and closed for modification. It'll be simple to add other notification types later.

The other reason for literal string types is that you can use them to identify the type of an object via a discriminated union. Let's extend our example above to see how this works:

type EmailNotification = {
    type: "email";
    content: string;
    recipientEmailAddress: string;
}

type SmsNotification = {
    type: "sms";
    content: string;
    recipientPhoneNumber: string;
}

type Notification = EmailNotification | SmsNotification;

We have two specific Notification types, and a union type made from all our more specific types.

Now, let's say we need a send function:

function send(n: Notification) {
    // ...
}

This function takes any Notification, but it needs to do something different depending on what type of notification it is. However, since Notification is the union of our email and sms types, we only have access to the type and content fields, since those are the only fields that are shared.

This is where that string literal type comes in handy. It allows us to discriminate between the types in the union:

function send(n: Notification) {
    switch(n.type) {
        case "email":
            return sendEmail(n);
        case "sms":
            return sendSms(n);
        default:
            unreachable(n);
    }
}

function sendEmail(emailNotif: EmailNotification) {}

function sendSms(smsNotif: SmsNotification) {}

function unreachable(x: never): never {
    throw new Error("unreachable!");
}

There are a number of cool things going on here.

First, the differentiation. As we already established, the argument n is of type Notification, so we can't access the recipientEmailAddress or recipientPhoneNumber fields. However, because the type field is a literal string (and not just the type string) for both, TypeScript can use that to narrow the type of n. That is, we can discriminate between the types in our union (Notification) by comparing n.type. This means that inside the case statements, n is now known to be an EmailNotification or SmsNotification, and we can treat it as such.

Secondly, we're using a pattern called exhaustive switch here — that is, we're using TypeScript to guarantee that our switch statement covers all possible values for n.type. Because of the discrimination behavior, TypeScript knows that if we read the default case, there's no other possible type for n, and so it will be never. We have a little utility function that takes a never and throws an error. This performs double duty. Most obviously, it will throw an error if we ever hit the default case. But even better, we have a "compile time" error: if we add a new type to our union — say, PigeonNotification with type: "pigeon" — and we forget to add a case statement for that, then we'll get an error on our call to unreachable:

Argument of type 'PigeonNotification' is not assignable to parameter of type 'never'.

Of course, with language servers running in the editor, this compile time error becomes a "coding time" error, and we get an inline reminder to update the send function.

String Unions

We can use literal strings to create their own unions as well. For example:

type NotificationType = "email" | "sms" | "pigeon";

This is actually the same as using the square bracket notation on a type to get the type of a field within it:

type NotificationType = Notification["type"];

A string union is a great way to represent a discrete set of values. Types of notifications? Absolutely! How about the possible states of a job that sends notifications? You bet:

type JobState = "enqueued" | "running" | "failed" | "complete";

Or, what about the list of the names of the databases your app connects to? Yep:

type DatabaseName = "users" | "widgets"  | "events";

You can do a couple cool things with these string unions.

First, you can create permutations of multiple unions:

type NotificationType = "email" | "sms" | "pigeon";
type JobState = "enqueued" | "running" | "failed" | "complete";

type NotifcationJobState = `${NotificationType}${Capitalize<JobState>}`;

Notice that we can use the template literal syntax to create a type. We can use this exactly like we do in regular JavaScript, where we include both literal characters and variables for replacement.

We're also using one of TypeScript's string manipulation types, Capitalize, which capitalizes the first letter of the string. TypeScript offers four such string manipulation types:

  • Uncapitalize
  • Capitalize
  • Uppercase
  • Lowercase

But what exactly is the result of all this? What's the resulting type NotificationJobState? Well, it's another string union, one with all permutations of the two string unions "inside" it. It's the equivalent of this:

type NotificationJobState = "emailEnqueued" | "emailRunning" | "emailFailed" | "emailComplete" | "smsEnqueued" | ... | "pigeonComplete";

Of course, the benefit of creating one type based on another is that all your types will be kept "in sync" — if you add a new notification type or job state, the new values will be part of the permutations.

Mapping String Types

We can use string unions to create more complex types using type mapping. Let's create a DatabaseConfigs type based on that DatabaseName string union we have above

type DatabaseName = "users" | "widgets"  | "events";

type DatabaseConfigs = {
    [key in DatabaseName]: {
        host: string;
        port: number;
        user: string;
        pass: string
    }
};

The key in OtherType syntax is the mapping. This means that an instance of DatabaseConfigs needs to have three properties matching strings in DatabaseName.

Mapped types do save you some keystrokes, but they also improve your development experience. Let’s say we have our DatabaseConfigs instance:

const dbConfig: DatabaseConfigs = {
    users: { ... },
    widgets: { ... },
    events: { ... }
}

If we add a new database to our app (say, orders) and we add its name to the DatabaseName string union, it will automatically become part of the DatabaseConfig type. We’ll immediately get a TypeScript error at our dbConfig object, saying that it’s missing an orders field, and reminding us to add the connection details.

Unions with keyof

There's another way you can create string unions: using the keyof keyword. In conjunction with another type, keyof will create a union from all the keys on that type.

type User = {
    firstName: string;
    lastName: string;
    age: number;
    verified: boolean;
}

type UserField = keyof User;

// equivalent to "firstName" | "lastName" | "age" | "verified"

We can use this with type mapping and the template literal syntax to do some cool and complex stuff:

type User = {
    firstName: string;
    lastName: string;
    age: number;
    verified: boolean;
}

type UserGetter = {
    [Key in keyof User as `get${Capitalize<Key>}`]: () => User[Key];
}

type UserSetter = {
    [Key in keyof User as `set${Capitalize<Key>}`]: (arg: User[Key]) => void;
}

We're putting keyof User inline here, but we could just as easily create an explicit type for it. We're also using the in-as syntax for mapping here, which allows us to transform the key using a template literal. In our case, we're ensuring that our UserGetter and UserSetter types will use conventional casing for their method names. These two types will make it easy for us to ensure that any time we add new fields to our User type, we'll be reminded to add the correct methods (with the correct types!) to anything implementing UserGetter and UserSetter.

Read-Only Strings

Let's wrap up with an interesting example of crossing the compile time and runtime boundary. We know that when TypeScript is transpiled to JavaScript, the types are stripped out. Because of this, we sometimes "know better" than the compiler. Check out this example:

type User = {
    firstName: string;
    lastName: string;
    age: number;
    verified: boolean;
}

const u: User = {
  firstName: "Jane",
  lastName: "Doe",
  age: 50,
  verified: true
};

const keys = ["firstName", "lastName"];

keys.forEach(key => {
  u[key] = u[key].toLowerCase();
});

We have an instance of our User type, and we want to iterate over an explicit subset of the keys, so we put them in an array. We can read this code and know that it works fine.

However, TypeScript complains:

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'User'.

The problem is that TypeScript considers keys to have the type Array<string>, which is too "wide" to be used to index into our user (u[key]). The array could include strings that aren't keys of User!

You might think that this is the solution, because it limits the array to including only strings that are keys of User:

const keys: Array<keyof User> = ["firstName", "lastName"];

This will solve that problem, but another one pops up:

Property 'toLowerCase' does not exist on type 'string | number | boolean'.

Now we can index into the object with u[key], but we can't know for sure that we're operating on a string, since User includes non-string values.

The cleanest way to do this is using as const:

const keys = ["firstName", "lastName"] as const;

// equivalent to
const keys: readonly ["firstName", "lastName"] = ["firstName", "lastName"];

You’ve likely used const to create a variable with an unchangeable value, but this is different. When you append as const to a value, you’re telling TypeScript that the type of this object exactly matches the object itself; that is, all the fields in the object (or items in the array) are literal values, just like in the example we started with, where Notification’s type field is a string literal type.

In this case, it will give keys the type of a read-only tuple with exactly those two strings inside it.

Because they're string literals, TypeScript can validate that u[key] is always a string. And because keys is constant, or read-only, trying to do something like keys.push("age") or keys[2] = "verified" would result in TypeScript throwing an error.

One final note: you don’t need to use as const with primitive values: if no type information is given, TypeScript will infer that they are literal types.

type NotificationType = "email" | "sms" | "pigeon";
type JobState = "enqueued" | "running" | "failed" | "complete";

type NotifcationJobState = `${NotificationType}_${JobState}`;

function update(s: NotifcationJobState) {}

const t = "email";
const s = "running";
update(`${t}_${s}`)

This works because the type of t is ”email”, not string; same with s. If either had the type string, this would cause a type error.

Conclusion

TypeScript takes the humble string and makes it a powerful tool for well-typed applications. We use all of these techniques to reduce errors and keep our whole codebase flexible as it grows.

    As you might know, ClassDojo offers a paid subscription for parents, to give them extra tools for interacting with both their teachers and their kids. For several years, we built and supported a system for managing those subscriptions. That’s non-trival work: it involves integrations with several app stores and payment providers and wrangling different data models for each.

    In the last year, we’ve switched to a third-party solution: RevenueCat. It’s not easy switching payment systems when you’ve got several years of data in the old system, and need to keep it running until the new integration is ready to go.

    I recently sat down with Urjit and Sarah, two of the engineers who led this project, and asked them about the challenges they faced and the lessons they learned as they replaced the wheels of our bus without making a pit stop. You can listen to the conversation below:

    Listen to Episode 1, Building Payment Systems

    Note: we're still in the process of setting up a podcast feed, so stay tuned for that!

    • Engineering Dojo Podcast

    Bob Nystrom's What Color is Your Function does an amazing job of describing why it can be painful when programming languages have different rules for calling synchronous and asynchronous functions. Promises and async/await have simplified things in JavaScript, but it's still a language with "red" (async) and "blue" (sync) functions, and I consistently see a few understandable errors from red vs. blue function confusion. Let's go through some of the most common mistakes – none of these are bad things to get wrong, they're just a symptom of how confusing this can be!

    Omitting await from try/catch blocks

    The most common mistake I see is omitting await from try/catch blocks with async functions. The code looks reasonable, but the catch block will only be able to catch synchronously thrown errors. To make matters worse, error handling logic is often less well tested than the happy path when everything works, which makes this pattern more likely to sneak its way into production code.

    async function throwsError () {
      throw new Error("alas! an error");
    }
    
    try {
      return throwsError();
    } catch (err) {
      console.error("Oh no! This catch block isn't catching anything", err);
    }
    

    An async function that throws is the equivalent of a Promise.reject, and when written that way, it's a bit clearer what's going on:

    try {
      return Promise.reject(new Error("alas! an error"));
    } catch (err) {
      console.error("It's clearer that this `catch` can't catch that `Promise.reject`. This is equivalent to the earlier code");
    }
    

    Personally, I'm starting to wonder whether using try and catch blocks at all is a mistake when dealing with async code. They take up space and don't offer the same pattern matching that a library like Bluebirdjs can add to catch when you only want to catch some specific known errors: await tryThing().catch(NotFoundErrorClass, handleErrPattern) feels substantially cleaner to me than the equivalent try/catch block.

    Array.filter(async () => false)

    In recent years, JavaScript has added lots of useful Array methods like filter, map, forEach, and flatMap, and JavaScript programmers often use libraries like lodash to write functional code rather than writing for loops. Sadly, none of those Array methods or lodash helpers work with red async functions and are a common source of coding errors.

    const things = [true, false, 1, 0, "", new Date("not a date") - 0];
    const filteredThings = things.filter(async (thing) => thing);
    

    How many things do we end up with in filteredThings? Surprisingly, the answer has little to do with JavaScript type coercion: filteredThings will be the same size as things. An async function returns a Promise and even a Promise that resolves to false is still a truthy value: Boolean(Promise.resolve(false)) === true. If we want to do any sort of filtering using an async function, we need to switch out of blue sync mode and into red async mode.

    (async function () {
      // You should use a library like Bluebird rather than filtering like this! this is only for illustration
      const things = [true, false, 1, 0, "", new Date("not a date") - 0];
      const predicateValues = await Promise.all(things.map(async (thing) => thing));
      const filteredThings = things.filter((_thing, i) => predicateValues[i]);
    })();
    

    When you see Array.filter(async (thing) => thing) written out like that, the mistake is pretty clear. It can be harder to notice when you see code like const goodThings = things.filter(isGoodThing); you need to check whether isGoodThing is red or blue.

    Array.forEach(async...

    We see a similar problem when people use Array.forEach with an async function:

    const fruitStatus = {};
    ["apple", "tomato", "potato"].forEach(async (food) => {
      fruitStatus[food] = await isFruit(food);
    });
    return fruitStatus;
    

    In some ways, this is a more dangerous pattern. Depending on when you check, fruitStatus may have some, none, or all of the correct isFruit values. If isFruit is normally fast, problems and bugs might not manifest until isFruit slows down. A bug that only shows up some of the time is much harder to debug than one that's always there.

    Await off my shoulders

    Despite how easy it is to make mistakes with async/await, I still love it – it feels easier to work with than Promises or callbacks. Dealing with asynchronous code is still one of the harder parts of programming in JavaScript, but tools like bluebird, the TypesScript no-unnecessary-condition rule, and the eslint promise plugin can help surface these easy-to-make red/blue function mistakes early. Hopefully, seeing the mistakes we often make will help you avoid some frustrating minutes debugging.

      Newer posts
      Older posts