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?

1type Notification = {
2 type: "email";
3 content: string;
4}

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:

1type EmailNotification = {
2 type: "email";
3 content: string;
4 recipientEmailAddress: string;
5}
6
7type SmsNotification = {
8 type: "sms";
9 content: string;
10 recipientPhoneNumber: string;
11}
12
13type 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:

1function send(n: Notification) {
2 // ...
3}

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:

1function send(n: Notification) {
2 switch(n.type) {
3 case "email":
4 return sendEmail(n);
5 case "sms":
6 return sendSms(n);
7 default:
8 unreachable(n);
9 }
10}
11
12function sendEmail(emailNotif: EmailNotification) {}
13
14function sendSms(smsNotif: SmsNotification) {}
15
16function unreachable(x: never): never {
17 throw new Error("unreachable!");
18}

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:

1Argument 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:

1type 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:

1type 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:

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

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

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

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

First, you can create permutations of multiple unions:

1type NotificationType = "email" | "sms" | "pigeon";
2type JobState = "enqueued" | "running" | "failed" | "complete";
3
4type 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:

1type 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

1type DatabaseName = "users" | "widgets" | "events";
2
3type DatabaseConfigs = {
4 [key in DatabaseName]: {
5 host: string;
6 port: number;
7 user: string;
8 pass: string
9 }
10};

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:

1const dbConfig: DatabaseConfigs = {
2 users: { ... },
3 widgets: { ... },
4 events: { ... }
5}

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.

1type User = {
2 firstName: string;
3 lastName: string;
4 age: number;
5 verified: boolean;
6}
7
8type UserField = keyof User;
9
10// 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:

1type User = {
2 firstName: string;
3 lastName: string;
4 age: number;
5 verified: boolean;
6}
7
8type UserGetter = {
9 [Key in keyof User as `get${Capitalize<Key>}`]: () => User[Key];
10}
11
12type UserSetter = {
13 [Key in keyof User as `set${Capitalize<Key>}`]: (arg: User[Key]) => void;
14}

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:

1type User = {
2 firstName: string;
3 lastName: string;
4 age: number;
5 verified: boolean;
6}
7
8const u: User = {
9 firstName: "Jane",
10 lastName: "Doe",
11 age: 50,
12 verified: true
13};
14
15const keys = ["firstName", "lastName"];
16
17keys.forEach(key => {
18 u[key] = u[key].toLowerCase();
19});

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:

1Element 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:

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

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

1Property '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:

1const keys = ["firstName", "lastName"] as const;
2
3// equivalent to
4const 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.

1type NotificationType = "email" | "sms" | "pigeon";
2type JobState = "enqueued" | "running" | "failed" | "complete";
3
4type NotifcationJobState = `${NotificationType}_${JobState}`;
5
6function update(s: NotifcationJobState) {}
7
8const t = "email";
9const s = "running";
10update(`${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.

Andrew Burgess

Andrew loves full stack engineering, improving complex code bases, and building great teams.

    Next Post
    Previous Post