DEV Community

Cover image for Leveraging TypeScript branded types for stronger type checks
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Leveraging TypeScript branded types for stronger type checks

Written by Rashedul Alam✏️

Branded types in TypeScript allow us to write code more clearly and provide more type-safe solutions. This powerful feature is also very simple to implement and can help us maintain our code more efficiently.

In this article, we’ll learn how to use branded types effectively in our TypeScript code, starting with a simple example and moving on to some advanced use cases and more. Let’s get started.

What are branded types in TypeScript?

Branded types in TypeScript are very powerful and efficient features for better code readability, context, and type safety code writing. They give an extra definition of our existing type. Thus, we can compare entities with similar structures and file names.

For example, instead of having the user email in the string, we can create a branded TypeScript type for the email address and separate it from a normal string. This enables us to validate our entity in a more organized way and clarifies the code.

Without branded types, we usually store values in generic typed variables, which is typical in most cases. Working with branded types lets us emphasize that variable and maintain its validity across the code.

A simple example of branded TypeScript types

Let’s create a branded type for email addresses. We can create a branded type by adding a brand name to the type. In this case, the branded type for the email address will be the following:

type EmailAddress = string & { __brand: "EmailAddress" }
Enter fullscreen mode Exit fullscreen mode

Here, we have created the branded type EmailAddress by attaching a __brand name "EmailAddress".

Note that there’s no generic syntax to follow when creating branded types. It mainly depends on the generic type of that branded type — string, number, etc.. The syntax __brand is also not a reserved word, which means we can have any variable name other than __brand.

Now, let’s create an object of type EmailAddress and pass a string:

const email: EmailAddress = 'asd'; // error
Enter fullscreen mode Exit fullscreen mode

As we can see, we are getting a type error stating:

Type 'string' is not assignable to type 'EmailAddress.' Type 'string' is not assignable to type '{ __brand: "EmailAddress"; }.'
Enter fullscreen mode Exit fullscreen mode

To fix this, let’s create a basic validation for our email address:

const isEmailAddress = (email: string): email is EmailAddress => {
  return email.endsWith('@gmail.com');
};
Enter fullscreen mode Exit fullscreen mode

Here, instead of returning a boolean, we are returning email is EmailAddress. That means the email is type casted to EmailAddress if the function returns true. This helps us validate the string before performing any operations on it:

const sendVerificationEmail = (email: EmailAddress) => {
  //...
}
const signUp = (email: string, password: string) => {
  //...
  if (isEmailAddress(email)) {
    sendVerificationEmail(email) // pass
  }
  sendVerificationEmail(email) // error
}
Enter fullscreen mode Exit fullscreen mode

As we can see, the error is not visible inside the if condition, but occurs outside that condition's scope.

We can also use assert to validate the email address. This can sometimes be useful if we want to throw an error if the validation doesn't pass:

function assertEmailAddress(email: string): asserts email is EmailAddress {
  if (!email.endsWith('@gmail.com')) {
    throw new Error('Not an email addres');
  }
};

const sendVerificationEmail = (email: EmailAddress) => {
  //...
};

const signUp = (email: string, password: string) => {
  //...
  assertEmailAddress(email);
  sendVerificationEmail(email); // ok
};
Enter fullscreen mode Exit fullscreen mode

Here, as we can see, we are stating asserts email is EmailAddress as the returned type to the function. This ensures that if the validation passes, the email address is of the branded type EmailAddress.

More advanced uses for branded types in TypeScript

The example above is a simple demonstration of the branded type. We can use it in more advanced cases as well. Let’s see an example.

First, let’s declare a common Branded type, which we can attach to other types:

declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }
export type Branded<T, B> = T & Brand<B>
Enter fullscreen mode Exit fullscreen mode

Here, a unique symbol is used in branded types to create a unique brand that distinguishes one type from another. It's a symbol that is guaranteed to be unique. This means that each time you create a new unique symbol, it will be distinct from any other symbol. Here's an example to illustrate this:

// Define unique symbols to use as brands
const metersSymbol: unique symbol = Symbol("meters");
const kilometersSymbol: unique symbol = Symbol("kilometers");

// Define branded types
type Meters = number & { [metersSymbol]: void };
type Kilometers = number & { [kilometersSymbol]: void };

// Helper functions to create branded values
function meters(value: number): Meters {
  return value as Meters;
}

function kilometers(value: number): Kilometers {
  return value as Kilometers;
}

// Variables with branded types
const distanceInMeters: Meters = meters(100);
const distanceInKilometers: Kilometers = kilometers(1);

// The following assignments will cause type errors
const wrongDistance: Meters = distanceInKilometers;
const anotherWrongDistance: Kilometers = distanceInMeters;

// Correct usage
const anotherDistanceInMeters: Meters = meters(200);
const anotherDistanceInKilometers: Kilometers = kilometers(2);

console.log(distanceInMeters, distanceInKilometers);
Enter fullscreen mode Exit fullscreen mode

Having a common Branded type interface allows us to create multiple branded types in TypeScript simultaneously, reducing code implementation and making the code much cleaner. Now, expanding on our above email validation example, we can use this common Branded type to define the EmailAddress brand like so:

type EmailAddress = Branded<string, 'EmailAddress'>;

const isEmailAddress = (email: string): email is EmailAddress => {
  return email.endsWith('@gmail.com');
};

const sendEmail = (email: EmailAddress) => {
  // ...
};

const signUp = (email: string, password: string) => {
  if (isEmailAddress(email)) {
    // send verification email
    sendEmail(email);
  }
};
Enter fullscreen mode Exit fullscreen mode

We can now use this Branded type to create a new branded TypeScript type. Let’s look at another example of using the Branded type. Let’s say we are writing a function to allow a user to like a post. We can use the Branded type in both our userId and postId:

type UserId = Branded<string, 'UserId'>;
type PostId = Branded<string, 'PostId'>;

type User = {
  userId: UserId;
  username: string;
  email: string;
};

type Post = {
  postId: PostId;
  title: string;
  description: string;
  likes: Like[];
};

type Like = {
  userId: UserId;
  postId: PostId;
};

const likePost = async (userId: UserId, postId: PostId) => {
  const response = await fetch(`/posts/${postId}/like/${userId}`, {
    method: 'post',
  });
  return await response.json();
};

// fake objects
const user: User = {
  userId: "1" as UserId,
  email: "a@email.com",
  username: "User1"
}
const post: Post = {
  postId: "2" as PostId,
  title: "Sample Title",
  description: "Sample post description",
  likes: []
}

likePost(user.userId, post.postId) // ok
likePost(post.postId, user.userId) // error
Enter fullscreen mode Exit fullscreen mode

Working with branded types in TypeScript 5.5-beta

With the new TypeScript 5.5-beta release, TypeScript’s control flow analysis can now track how the type of a variable changes as it moves through the code.

This means the types of variables can change based on the code logic, and TypeScript tracks the variable type in each modification chain of any code logic. If there are two possible types of a variable, we can split the types of the variable by applying the necessary condition.

Let’s look at the following code for better understanding:

interface ItemProps {
    // ...
}

declare const items: Map<string, ItemProps>;

function getItem(id: string) {
  const item = items.get(id);  // item has a declared type of ItemProps | undefined
  if (item) {
    // item has type ItemProps inside the if statement
  } else {
    // item has type undefined here.
  }
}

function getAllItemsByIds(ids: string[]): ItemProps[] {
    return ids.map(id => items.get(id)).filter(item => item !== undefined) // Previously we would get error: Type '(ItemProps | undefined)[]' is not assignable to type 'ItemProps[]'. Type 'ItemProps | undefined' is not assignable to type 'ItemProps'. Type 'undefined' is not assignable to type 'ItemProps'
}
Enter fullscreen mode Exit fullscreen mode

Let’s look at the following example. In this example, we get a list of email addresses and store the validated email addresses in the database. Using the previous example, we can create a branded EmailAddress type and store the validated emails in the database:

type EmailAddress = Branded<string, 'EmailAddress'>;
const isEmailAddress = (email: string): email is EmailAddress => {
  return email.endsWith('@gmail.com');
};
const storeToDb = async (emails: EmailAddress[]) => {
  const response = await fetch('/store-to-db', {
    body: JSON.stringify({
      emails,
    }),
    method: 'post',
  });
  return await response.json();
};
const emails = ['a@gmail.com', 'b@gmail.com', '...'];
const validatedEmails = emails.filter((email) => isEmailAddress(email));
storeToDb(validatedEmails); // error
Enter fullscreen mode Exit fullscreen mode

Here, we can see that, although we are listing all the validated email addresses in the validatedEmails array, we are getting an error while passing inside the storeToDb function. This error is especially visible if we use a TypeScript version prior to v5.5-beta.

In lower TypeScript types, the type of the validatedEmails array is derived from the original variable, the emails array. That’s why we are getting the types of validatedEmails array as string[]. However, this issue is fixed in the current TypeScript beta version (5.5-beta as of today).

In the current beta version, the validatedEmails are automatically typecasted to EmailAddress[] after we filter the validated emails. So, we will not see the error in the TypeScript 5.5-beta version. To install the TypeScript beta version in our project, run the following command in the terminal:

npm install -D typescript@beta
Enter fullscreen mode Exit fullscreen mode

Conclusion

Branded types are handy features in TypeScript. They provide runtime type safety to ensure code integrity and better readability. They are also very useful for reducing bugs in our code by throwing errors at a domain level.

We can easily validate our branded types and use the validated objects safely across our projects. We can also use the latest TypeScript beta version features to leverage our coding experience more smoothly.


LogRocket: Full visibility into your web and mobile apps

LogRocket Signup

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

Try it for free.

Top comments (0)