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 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.
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 nowFix sticky positioning issues in CSS, from missing offsets to overflow conflicts in flex, grid, and container height constraints.
From basic syntax and advanced techniques to practical applications and error handling, here’s how to use node-cron.
The Angular tree view can be hard to get right, but once you understand it, it can be quite a powerful visual representation.
In this post, we’ll compare Babel and SWC based on setup, execution, and speed.