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" }
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
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"; }.'
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');
};
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
}
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
};
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>
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);
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);
}
};
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
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'
}
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
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
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 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.
Top comments (0)