In this guide, we will explore the concept of branded types in TypeScript and learn how to create and work with them using the Brand module.
Branded types are TypeScript types with an added type tag that helps prevent accidental usage of a value in the wrong context.
They allow us to create distinct types based on an existing underlying type, enabling type safety and better code organization.
The Problem with TypeScript’s Structural Typing
TypeScript’s type system is structurally typed, meaning that two types are considered compatible if their members are compatible.
This can lead to situations where values of the same underlying type are used interchangeably, even when they represent different concepts or have different meanings.
Consider the following types:
Here, UserId and ProductId are structurally identical as they are both based on number.
TypeScript will treat these as interchangeable, potentially causing bugs if they are mixed up in your application.
Example (Unintended Type Compatibility)
In the example above, passing a UserId to getProductById does not produce a type error, even though it’s logically incorrect. This happens because both types are considered interchangeable.
How Branded Types Help
Branded types allow you to create distinct types from the same underlying type by adding a unique type tag, enforcing proper usage at compile-time.
Branding is accomplished by adding a symbolic identifier that distinguishes one type from another at the type level.
This method ensures that types remain distinct without altering their runtime characteristics.
Let’s start by introducing the BrandTypeId symbol:
This approach assigns a unique identifier as a brand to the number type, effectively differentiating ProductId from other numerical types.
The use of a symbol ensures that the branding field does not conflict with any existing properties of the number type.
Attempting to use a UserId in place of a ProductId now results in an error:
Example (Enforcing Type Safety with Branded Types)
The error message clearly states that a number cannot be used in place of a ProductId.
TypeScript won’t let us pass an instance of number to the function accepting ProductId because it’s missing the brand field.
Let’s add branding to UserId as well:
Example (Branding UserId and ProductId)
The error indicates that while both types use branding, the unique values associated with the branding fields ("ProductId" and "UserId") ensure they remain distinct and non-interchangeable.
Generalizing Branded Types
To enhance the versatility and reusability of branded types, they can be generalized using a standardized approach:
This design allows any type to be branded using a unique identifier, either a string or symbol.
Here’s how you can utilize the Brand interface, which is readily available from the Brand module, eliminating the need to craft your own implementation:
Example (Using the Brand Interface from the Brand Module)
However, creating instances of these types directly leads to an error because the type system expects the brand structure:
Example (Direct Assignment Error)
You cannot directly assign a number to ProductId. The Brand module provides utilities to correctly construct values of branded types.
Constructing Branded Types
The Brand module provides two main functions for creating branded types: nominal and refined.
nominal
The Brand.nominal function is designed for defining branded types that do not require runtime validations.
It simply adds a type tag to the underlying type, allowing us to distinguish between values of the same type but with different meanings.
Nominal branded types are useful when we only want to create distinct types for clarity and code organization purposes.
Example (Defining Distinct Identifiers with Nominal Branding)
Attempting to assign a non-ProductId value will result in a compile-time error:
Example (Type Safety with Branded Identifiers)
refined
The Brand.refined function enables the creation of branded types that include data validation. It requires a refinement predicate to check the validity of input data against specific criteria.
When the input data does not meet the criteria, the function uses Brand.error to generate a BrandErrors data type. This provides detailed information about why the validation failed.
Example (Creating a Branded Type with Validation)
Example (Using the Int Constructor)
Attempting to assign a non-Int value will result in a compile-time error:
Example (Compile-Time Error for Incorrect Assignments)
Combining Branded Types
In some cases, you might need to combine multiple branded types. The Brand module provides the Brand.all API for this purpose: