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.
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.
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
.
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: "[email protected]", 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
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 = ['[email protected]', '[email protected]', '...']; 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
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 lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks, and with plugins to log additional context from Redux, Vuex, and @ngrx/store.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you understand your web and mobile apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowYou don’t need to guess what’s wrong with your Next.js app. I’ve mapped out the 8 biggest performance traps and the fixes that actually work.
Learn how to truncate text with three dots in CSS, and use two reliable CSS text truncation techniques while covering single-line and multi-line truncations.
Explore how to use Google’s new experimental Interest Invoker API for delays, popovers, and smarter hover UX.
Bolt.new revolutionizes how you build and deploy web apps with no-code development and seamless AI integration.