What Are Branded Types?
Branded types in TypeScript enhance type safety by attaching a unique "brand" to existing types, creating a distinction at compile time without runtime overhead. This is particularly useful for refining types that are too general, like differentiating between different kinds of strings or numbers.
Creating Branded Types
To create a branded type, you typically intersect a base type with an object containing a unique property. This property, often represented by a unique symbol, ensures that the branded type is distinguishable from its base type.
type Brand<B> = { __brand: B }
export type Branded<T, B> = T & Brand<B>
declare const metersSymbol: unique symbol;
declare const kilometersSymbol: unique symbol;
type Meters = number & { [metersSymbol]: void };
type Kilometers = number & { [kilometersSymbol]: void };
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
Use Cases
1. Ensuring Type Specificity:
Branded types help enforce specific types where generic ones would be too broad. For instance, using Meters and Kilometers ensures that functions expecting distances don't mistakenly interchange units.
2. Validating Complex Data:
In scenarios where certain inputs must meet specific criteria (e.g., positive numbers, percentages), branded types can ensure these constraints are respected at compile time.
type PositiveNumber = number & { __brand: 'PositiveNumber' };
function assertPositiveNumber(x: number): asserts x is PositiveNumber {
if (x < 0) throw new Error('Number is not positive');
}
3. Avoiding Type Collisions:
Branded types prevent type collisions in large codebases where different parts of the application might use the same base types differently.
Problems Solved
Improved Type Safety:
By ensuring that only valid values can be assigned to variables, branded types catch potential errors early, during development, rather than at runtime.
Clearer Code Intentions:
Branded types make the developer's intentions explicit, reducing the risk of misuse. For example, a Hash branded string clearly indicates its purpose compared to a generic string.
type Hash = Branded<string, 'Hash'>;
const generateHash = (input: string): Hash => {
return ("hashed_" + input) as Hash;
};
Prevention of Logical Errors:
Functions that require specific kinds of inputs (e.g., percentages) benefit from branded types, ensuring that invalid values (e.g., 20 instead of 0.2) are caught at compile time.
Downsides
While branded types in TypeScript offer significant benefits in terms of type safety and clarity, they do come with some downsides.
Intellisense Visibility: The __brand property might appear in Intellisense, potentially confusing developers who try to use it.
Increased Verbosity: Branded types require additional type assertions, which can make the code more verbose.
Possible Type Collisions: If different libraries or parts of the codebase use the same brand name, it can lead to unintentional type equivalence.
Addressing the Downsides of Branded Types
- Intellisense Visibility: Solution: To prevent the __brand property from appearing in Intellisense, use unique symbols and keep the brand definition encapsulated within modules. By using unique symbol for the branding property, you ensure that the property is hidden and less likely to cause confusion.
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
- Increased Verbosity: Solution: Create utility functions to streamline the creation and validation of branded types. This reduces boilerplate code and makes it easier to use branded types throughout the codebase.
type Brand<B> = { __brand: B };
type Branded<T, B> = T & Brand<B>;
function createBranded<T, B>(value: T): Branded<T, B> {
return value as Branded<T, B>;
}
function isBranded<T, B>(value: T): value is Branded<T, B> {
return '__brand' in value;
}
- Possible Type Collisions: Solution: Use unique symbol for branding properties to avoid accidental collisions with other libraries. This ensures that each branded type remains distinct.
declare const userIdBrand: unique symbol;
declare const postIdBrand: unique symbol;
type UserId = number & { [userIdBrand]: void };
type PostId = number & { [postIdBrand]: void };
- Type Assertions: Solution: Use assertion functions to encapsulate the type checks and branding, reducing the need for explicit type assertions scattered throughout the code. This also improves readability and maintainability.
function assertPositiveNumber(value: number): asserts value is PositiveNumber {
if (value < 0) throw new Error('Value must be a positive number');
}
type PositiveNumber = number & { __brand: 'PositiveNumber' };
- Complexity for New Developers: Solution: Document the use of branded types clearly within the codebase and provide examples of common use cases. Educate team members on the benefits and usage patterns of branded types during code reviews and onboarding.
Example of Streamlined Branded Types
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;
function createBranded<T, B>(value: T): Branded<T, B> {
return value as Branded<T, B>;
}
function assertPositiveNumber(value: number): asserts value is Branded<number, 'PositiveNumber'> {
if (value < 0) throw new Error('Value must be a positive number');
}
type PositiveNumber = Branded<number, 'PositiveNumber'>;
// Usage
const value: number = 10;
assertPositiveNumber(value);
const positiveValue: PositiveNumber = createBranded(value);
Branded types are a powerful tool in TypeScript for enhancing type safety and making code intentions clear. They are particularly useful in large applications where specific type constraints need to be enforced consistently.
Top comments (1)
Thank your for you article. How do you use branded types when exported ? More precisely, how do you manage the "declare const _brand", when the branded type is used in another package ?