Branded Types
On this page
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:
ts
typeUserId = numbertypeProductId = number
ts
typeUserId = numbertypeProductId = number
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.
For example:
ts
typeUserId = numbertypeProductId = numberconstgetUserById = (id :UserId ) => {// Logic to retrieve user}constgetProductById = (id :ProductId ) => {// Logic to retrieve product}constid :UserId = 1getProductById (id ) // No type error, but this is incorrect usage
ts
typeUserId = numbertypeProductId = numberconstgetUserById = (id :UserId ) => {// Logic to retrieve user}constgetProductById = (id :ProductId ) => {// Logic to retrieve product}constid :UserId = 1getProductById (id ) // No type error, but this is incorrect usage
In the example above, passing a UserId
to getProductById
should ideally throw a type error, but it doesn't due to structural compatibility.
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:
ts
constBrandTypeId : unique symbol =Symbol .for ("effect/Brand")typeProductId = number & {readonly [BrandTypeId ]: {readonlyProductId : "ProductId" // unique identifier for ProductId}}
ts
constBrandTypeId : unique symbol =Symbol .for ("effect/Brand")typeProductId = number & {readonly [BrandTypeId ]: {readonlyProductId : "ProductId" // unique identifier for ProductId}}
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:
ts
constgetProductById = (id :ProductId ) => {// Logic to retrieve product}typeUserId = numberconstid :UserId = 1Argument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'.2345Argument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'.getProductById () id
ts
constgetProductById = (id :ProductId ) => {// Logic to retrieve product}typeUserId = numberconstid :UserId = 1Argument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'.2345Argument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'.getProductById () id
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.
What if UserId
also had its own brand?
ts
constBrandTypeId : unique symbol =Symbol .for ("effect/Brand")typeProductId = number & {readonly [BrandTypeId ]: {readonlyProductId : "ProductId" // unique identifier for ProductId}}constgetProductById = (id :ProductId ) => {// Logic to retrieve product}typeUserId = number & {readonly [BrandTypeId ]: {readonlyUserId : "UserId" // unique identifier for UserId}}declare constid :UserId Argument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.2345Argument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.getProductById () id
ts
constBrandTypeId : unique symbol =Symbol .for ("effect/Brand")typeProductId = number & {readonly [BrandTypeId ]: {readonlyProductId : "ProductId" // unique identifier for ProductId}}constgetProductById = (id :ProductId ) => {// Logic to retrieve product}typeUserId = number & {readonly [BrandTypeId ]: {readonlyUserId : "UserId" // unique identifier for UserId}}declare constid :UserId Argument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.2345Argument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type '{ readonly [BrandTypeId]: { readonly ProductId: "ProductId"; }; }'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.getProductById () id
The error is saying that though both types utilize a branding strategy, the distinct values associated with their branding fields ("ProductId"
and "UserId"
) prevent them from being interchangeable.
Generalizing Branded Types
To enhance the versatility and reusability of branded types, they can be generalized using a standardized approach:
ts
constBrandTypeId : unique symbol =Symbol .for ("effect/Brand")// Create a generic Brand interface using a unique identifierinterfaceBrand <in outID extends string | symbol> {readonly [BrandTypeId ]: {readonly [id inID ]:ID }}// Define a ProductId type branded with a unique identifiertypeProductId = number &Brand <"ProductId">// Define a UserId type branded similarlytypeUserId = number &Brand <"UserId">
ts
constBrandTypeId : unique symbol =Symbol .for ("effect/Brand")// Create a generic Brand interface using a unique identifierinterfaceBrand <in outID extends string | symbol> {readonly [BrandTypeId ]: {readonly [id inID ]:ID }}// Define a ProductId type branded with a unique identifiertypeProductId = number &Brand <"ProductId">// Define a UserId type branded similarlytypeUserId = number &Brand <"UserId">
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:
ts
import { Brand } from "effect"// Define a ProductId type branded with a unique identifiertype ProductId = number & Brand.Brand<"ProductId">// Define a UserId type branded similarlytype UserId = number & Brand.Brand<"UserId">
ts
import { Brand } from "effect"// Define a ProductId type branded with a unique identifiertype ProductId = number & Brand.Brand<"ProductId">// Define a UserId type branded similarlytype UserId = number & Brand.Brand<"UserId">
However, creating instances of these types directly leads to an error because the type system expects the brand structure:
ts
constType 'number' is not assignable to type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.2322Type 'number' is not assignable to type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.: id ProductId = 1
ts
constType 'number' is not assignable to type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.2322Type 'number' is not assignable to type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.: id ProductId = 1
We need a way to create a value of type ProductId
without directly assigning a number to it. This is where the Brand module comes in.
Constructing Branded Types
The Brand module offers two core functions for constructing branded types: nominal
and refined
.
nominal
The 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.
ts
import {Brand } from "effect"typeUserId = number &Brand .Brand <"UserId">// Constructor for UserIdconstUserId =Brand .nominal <UserId >()constgetUserById = (id :UserId ) => {// Logic to retrieve user}typeProductId = number &Brand .Brand <"ProductId">// Constructor for ProductIdconstProductId =Brand .nominal <ProductId >()constgetProductById = (id :ProductId ) => {// Logic to retrieve product}
ts
import {Brand } from "effect"typeUserId = number &Brand .Brand <"UserId">// Constructor for UserIdconstUserId =Brand .nominal <UserId >()constgetUserById = (id :UserId ) => {// Logic to retrieve user}typeProductId = number &Brand .Brand <"ProductId">// Constructor for ProductIdconstProductId =Brand .nominal <ProductId >()constgetProductById = (id :ProductId ) => {// Logic to retrieve product}
Attempting to assign a non-ProductId
value will result in a compile-time error:
ts
// Correct usagegetProductById (ProductId (1))// Incorrect, will result in an errorArgument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.2345Argument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.getProductById (1 )// Also incorrect, will result in an errorArgument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type 'Brand<"ProductId">'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.2345Argument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type 'Brand<"ProductId">'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.getProductById (UserId (1))
ts
// Correct usagegetProductById (ProductId (1))// Incorrect, will result in an errorArgument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.2345Argument of type 'number' is not assignable to parameter of type 'ProductId'. Type 'number' is not assignable to type 'Brand<"ProductId">'.getProductById (1 )// Also incorrect, will result in an errorArgument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type 'Brand<"ProductId">'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.2345Argument of type 'UserId' is not assignable to parameter of type 'ProductId'. Type 'UserId' is not assignable to type 'Brand<"ProductId">'. Types of property '[BrandTypeId]' are incompatible. Property 'ProductId' is missing in type '{ readonly UserId: "UserId"; }' but required in type '{ readonly ProductId: "ProductId"; }'.getProductById (UserId (1))
refined
The 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.
ts
import {Brand } from "effect"// Define a branded type 'Int' to represent integer valuestypeInt = number &Brand .Brand <"Int">// Define the constructor using 'refined' to enforce integer valuesconstInt =Brand .refined <Int >(// Validation to ensure the value is an integer(n ) =>Number .isInteger (n ),// Provide an error if validation fails(n ) =>Brand .error (`Expected ${n } to be an integer`))
ts
import {Brand } from "effect"// Define a branded type 'Int' to represent integer valuestypeInt = number &Brand .Brand <"Int">// Define the constructor using 'refined' to enforce integer valuesconstInt =Brand .refined <Int >(// Validation to ensure the value is an integer(n ) =>Number .isInteger (n ),// Provide an error if validation fails(n ) =>Brand .error (`Expected ${n } to be an integer`))
Usage example of the Int
constructor:
ts
// Create a valid Int valueconstx :Int =Int (3)console .log (x ) // Output: 3// Attempt to create an Int with an invalid valueconsty :Int =Int (3.14) // throws [ { message: 'Expected 3.14 to be an integer' } ]
ts
// Create a valid Int valueconstx :Int =Int (3)console .log (x ) // Output: 3// Attempt to create an Int with an invalid valueconsty :Int =Int (3.14) // throws [ { message: 'Expected 3.14 to be an integer' } ]
Attempting to assign a non-Int
value will result in a compile-time error:
ts
// Correct usageconstgood :Int =Int (3)// Incorrect, will result in an errorconstType 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.2322Type 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.: bad1 Int = 3// Also incorrect, will result in an errorconstType 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.2322Type 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.: bad2 Int = 3.14
ts
// Correct usageconstgood :Int =Int (3)// Incorrect, will result in an errorconstType 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.2322Type 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.: bad1 Int = 3// Also incorrect, will result in an errorconstType 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.2322Type 'number' is not assignable to type 'Int'. Type 'number' is not assignable to type 'Brand<"Int">'.: bad2 Int = 3.14
Combining Branded Types
In some scenarios, you may need to combine multiple branded types together. The Brand module provides the all
API to facilitate this:
ts
import {Brand } from "effect"typeInt = number &Brand .Brand <"Int">constInt =Brand .refined <Int >((n ) =>Number .isInteger (n ),(n ) =>Brand .error (`Expected ${n } to be an integer`))typePositive = number &Brand .Brand <"Positive">constPositive =Brand .refined <Positive >((n ) =>n > 0,(n ) =>Brand .error (`Expected ${n } to be positive`))// Combine the Int and Positive constructors into a new branded constructor PositiveIntconstPositiveInt =Brand .all (Int ,Positive )// Extract the branded type from the PositiveInt constructortypePositiveInt =Brand .Brand .FromConstructor <typeofPositiveInt >// Usage example// Valid positive integerconstgood :PositiveInt =PositiveInt (10)// throws [ { message: 'Expected -5 to be positive' } ]constbad1 :PositiveInt =PositiveInt (-5)// throws [ { message: 'Expected 3.14 to be an integer' } ]constbad2 :PositiveInt =PositiveInt (3.14)
ts
import {Brand } from "effect"typeInt = number &Brand .Brand <"Int">constInt =Brand .refined <Int >((n ) =>Number .isInteger (n ),(n ) =>Brand .error (`Expected ${n } to be an integer`))typePositive = number &Brand .Brand <"Positive">constPositive =Brand .refined <Positive >((n ) =>n > 0,(n ) =>Brand .error (`Expected ${n } to be positive`))// Combine the Int and Positive constructors into a new branded constructor PositiveIntconstPositiveInt =Brand .all (Int ,Positive )// Extract the branded type from the PositiveInt constructortypePositiveInt =Brand .Brand .FromConstructor <typeofPositiveInt >// Usage example// Valid positive integerconstgood :PositiveInt =PositiveInt (10)// throws [ { message: 'Expected -5 to be positive' } ]constbad1 :PositiveInt =PositiveInt (-5)// throws [ { message: 'Expected 3.14 to be an integer' } ]constbad2 :PositiveInt =PositiveInt (3.14)