Before we understand what branded types are and why we need them, we must understand the meaning of the term "structural type system."
TypeScript is a structural type system. This means that two types are considered compatible if they have the same shape or structure, regardless of their declared names. TypeScript focuses on what properties a type has rather than what it's called.
The Problem with Structural Typing
Structural typing is flexible, but can lead to subtle bugs. For example, below we have two type aliases, UserId and ProductId. Both are just aliases for the string type, but in our application, they represent two very different concepts. A function fetchUserDetails requires a UserId to be passed.
TypeScript
1
type
typeUserId=string
UserId=string;
2
type
typeProductId=string
ProductId=string;
3
4
function
functionfetchUserDetails(id:UserId):void
fetchUserDetails(
id: string
id:
typeUserId=string
UserId) {
5
var console:Console
The console module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
A global console instance configured to write to process.stdout and
process.stderr. The global console can be used without importing the node:console module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O for
more information.
Example using the global console:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(newError('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
constname='Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console class:
constout=getStreamSomehow();
consterr=getStreamSomehow();
constmyConsole=new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(newError('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
Prints to stdout with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()).
Notice what happened in that last line: we passed myProductId to the fetchUserDetails function—a function that we intended to only accept a UserId—and TypeScript allowed it without any error.
This is the core problem with structural typing. Even though our intent is for UserId and ProductId to be completely different things, TypeScript sees them both as just string. From its point of view, their "structure" is identical, and they are therefore interchangeable.
To prevent this from happening and to make TypeScript recognize these two types as two different types, we can make use of the concept called Branded Types.
What is a Branded Type?
A branded type is a technique to simulate nominal typing (typing based on names) within TypeScript's structural system.
We "tag" or "brand" a base type (like string) with a unique, compile-time-only property. This "brand" doesn't exist at runtime, but it makes the type structurally unique from other types, even those with the same base type.
By doing this, UserId is no longer just a string; it becomes a string plus a "UserId" brand. And ProductId becomes a string plus a "ProductId" brand. Since their brands differ, TypeScript no longer sees them as the same.
Creating Branded Types
There are two primary ways we can create branded types.
1. Branded types using generics
We define a generic Branded type that can "tag" any type (T) with any brand (Brand):
TypeScript
1
type
typeBranded<T, Brand> =T& {
__brand:Brand;
}
Branded<
function (typeparameter) TintypeBranded<T, Brand>
T,
function (typeparameter) BrandintypeBranded<T, Brand>
Brand> =
function (typeparameter) TintypeBranded<T, Brand>
T& {
2
__brand: Brand
__brand:
function (typeparameter) BrandintypeBranded<T, Brand>
Brand;
3
};
Now, let's rewrite our initial example using this pattern.
TypeScript
1
type
typeBranded<T, Brand> =T& {
__brand:Brand;
}
Branded<
function (typeparameter) TintypeBranded<T, Brand>
T,
function (typeparameter) BrandintypeBranded<T, Brand>
Brand> =
function (typeparameter) TintypeBranded<T, Brand>
T& {
2
__brand: Brand
__brand:
function (typeparameter) BrandintypeBranded<T, Brand>
Brand;
3
};
4
5
type
typeUserId=string& {
__brand:"UserId";
}
UserId=
typeBranded<T, Brand> =T& {
__brand:Brand;
}
Branded<string, "UserId">;
6
type
typeProductId=string& {
__brand:"ProductId";
}
ProductId=
typeBranded<T, Brand> =T& {
__brand:Brand;
}
Branded<string, "ProductId">;
7
8
// Create "constructor" helper functions to safely create branded values
9
// (We use type assertion here to "brand" the plain string)
10
function
functionUserId(id:string):UserId
UserId(
id: string
id:string):
typeUserId=string& {
__brand:"UserId";
}
UserId {
11
return
id: string
idas
typeUserId=string& {
__brand:"UserId";
}
UserId;
12
}
13
14
function
functionProductId(id:string):ProductId
ProductId(
id: string
id:string):
typeProductId=string& {
__brand:"ProductId";
}
ProductId {
15
return
id: string
idas
typeProductId=string& {
__brand:"ProductId";
}
ProductId;
16
}
17
18
function
functionfetchUserDetails(id:UserId):void
fetchUserDetails(
id: UserId
id:
typeUserId=string& {
__brand:"UserId";
}
UserId) {
19
var console:Console
The console module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
A global console instance configured to write to process.stdout and
process.stderr. The global console can be used without importing the node:console module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O for
more information.
Example using the global console:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(newError('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
constname='Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console class:
constout=getStreamSomehow();
consterr=getStreamSomehow();
constmyConsole=new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(newError('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
Prints to stdout with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()).
Error ts(2345) ― Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.
Type 'ProductId' is not assignable to type '{ __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"ProductId"' is not assignable to type '"UserId"'.
Notice how TypeScript is now able to differentiate between these two types! Because we typed the fetchUserDetails function to only accept UserId, and we passed a ProductId, we get a type error.
2. Branded types using symbols
Let's first clarify what a symbol is. You might know Symbol() as a JavaScript feature (from ES2015) that, when called, creates a guaranteed-unique value. This runtime value is not equal to any other value, even one with the same description (e.g., Symbol('a') !== Symbol('a')).
In TypeScript, the type of one of these values is symbol (all lowercase). This is a general type, just like string or number.
However, for branding, this general symbol type isn't specific enough. We need a way to represent a specific, one-of-a-kind symbol at the type level, much like "UserId" is a specific string type, not just string.
This is where the special TypeScript-only type unique symbol comes in. Think of it as a compile-time "fingerprint" that is guaranteed to be absolutely unique to its declaration. No other unique symbol is ever considered equal to it.
When we use this unforgeable type as the key for our brand property, we create a structure that is impossible to replicate elsewhere.
Here's how it looks in practice:
TypeScript
1
// Declare unique symbols (they only exist at compile-time)
2
// We DON'T export these, keeping them private to this file.
3
declareconst
constuserIdBrand:typeof userIdBrand
userIdBrand:uniquesymbol;
4
declareconst
constproductIdBrand:typeof productIdBrand
productIdBrand:uniquesymbol;
5
6
// Define branded types using the symbols as computed keys
7
exporttype
typeUserId=string& {
[userIdBrand]:"UserId";
}
UserId=string& { [
constuserIdBrand:typeof userIdBrand
userIdBrand]:"UserId" };
8
exporttype
typeProductId=string& {
[productIdBrand]:"ProductId";
}
ProductId=string& { [
constproductIdBrand:typeof productIdBrand
productIdBrand]:"ProductId" };
9
10
// Create exported "constructor" functions
11
exportfunction
functionUserId(id:string):UserId
UserId(
id: string
id:string):
typeUserId=string& {
[userIdBrand]:"UserId";
}
UserId {
12
return
id: string
idas
typeUserId=string& {
[userIdBrand]:"UserId";
}
UserId;
13
}
14
15
exportfunction
functionProductId(id:string):ProductId
ProductId(
id: string
id:string):
typeProductId=string& {
[productIdBrand]:"ProductId";
}
ProductId {
16
return
id: string
idas
typeProductId=string& {
[productIdBrand]:"ProductId";
}
ProductId;
17
}
18
19
// --- In another file, we import our types and constructors ---
20
21
// import { UserId, ProductId } from './types';
22
23
function
functionfetchUserDetails(id:UserId):void
fetchUserDetails(
id: UserId
id:
typeUserId=string& {
[userIdBrand]:"UserId";
}
UserId) {
24
var console:Console
The console module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
A global console instance configured to write to process.stdout and
process.stderr. The global console can be used without importing the node:console module.
Warning: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the note on process I/O for
more information.
Example using the global console:
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(newError('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
constname='Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
Example using the Console class:
constout=getStreamSomehow();
consterr=getStreamSomehow();
constmyConsole=new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(newError('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
Prints to stdout with newline. Multiple arguments can be passed, with the
first used as the primary message and all additional used as substitution
values similar to printf(3)
(the arguments are all passed to util.format()).
Error ts(2345) ― Argument of type 'ProductId' is not assignable to parameter of type 'UserId'.
Property '[userIdBrand]' is missing in type 'String & { [productIdBrand]: "ProductId"; }' but required in type '{ [userIdBrand]: "UserId"; }'.
Notice we used the declare keyword to define our symbols. It tells TypeScript to pretend this const exists, only for type-checking purposes. It is not an actual JavaScript object or variable and will not exist at runtime. In essence, we create a "ghost" variable that only the type-checker can see, which is exactly what we want for branding.
The key advantage here is that because the symbols themselves are not exported, no other part of our application can ever create typee that are structurally compatible with UserId or ProductId. They are forced to use our exported UserId and ProductId constructor functions, giving us complete control and maximum safety.