How branded Types Work in TypeScript

Avatar of Hemanta Sundaray

Hemanta Sundaray

Published

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
type
type UserId = string
UserId
= string;
type
type ProductId = string
ProductId
= string;
function
function fetchUserDetails(id: UserId): void
fetchUserDetails
(
id: string
id
:
type UserId = string
UserId
) {
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(new Error('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
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = 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(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void

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()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(`Fetching details for User ID: ${
id: string
id
}`);
}
const
const myUserId: string
myUserId
:
type UserId = string
UserId
= "user-abc-123";
const
const myProductId: string
myProductId
:
type ProductId = string
ProductId
= "prod-xyz-789";
// ❌ This is the logical bug
function fetchUserDetails(id: UserId): void
fetchUserDetails
(
const myProductId: string
myProductId
);

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
type
type Branded<T, Brand> = T & {
__brand: Brand;
}
Branded
<
function (type parameter) T in type Branded<T, Brand>
T
,
function (type parameter) Brand in type Branded<T, Brand>
Brand
> =
function (type parameter) T in type Branded<T, Brand>
T
& {
__brand: Brand
__brand
:
function (type parameter) Brand in type Branded<T, Brand>
Brand
;
};

Now, let's rewrite our initial example using this pattern.

TypeScript
type
type Branded<T, Brand> = T & {
__brand: Brand;
}
Branded
<
function (type parameter) T in type Branded<T, Brand>
T
,
function (type parameter) Brand in type Branded<T, Brand>
Brand
> =
function (type parameter) T in type Branded<T, Brand>
T
& {
__brand: Brand
__brand
:
function (type parameter) Brand in type Branded<T, Brand>
Brand
;
};
type
type UserId = string & {
__brand: "UserId";
}
UserId
=
type Branded<T, Brand> = T & {
__brand: Brand;
}
Branded
<string, "UserId">;
type
type ProductId = string & {
__brand: "ProductId";
}
ProductId
=
type Branded<T, Brand> = T & {
__brand: Brand;
}
Branded
<string, "ProductId">;
// Create "constructor" helper functions to safely create branded values
// (We use type assertion here to "brand" the plain string)
function
function UserId(id: string): UserId
UserId
(
id: string
id
: string):
type UserId = string & {
__brand: "UserId";
}
UserId
{
return
id: string
id
as
type UserId = string & {
__brand: "UserId";
}
UserId
;
}
function
function ProductId(id: string): ProductId
ProductId
(
id: string
id
: string):
type ProductId = string & {
__brand: "ProductId";
}
ProductId
{
return
id: string
id
as
type ProductId = string & {
__brand: "ProductId";
}
ProductId
;
}
function
function fetchUserDetails(id: UserId): void
fetchUserDetails
(
id: UserId
id
:
type UserId = string & {
__brand: "UserId";
}
UserId
) {
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(new Error('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
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = 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(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void

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()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(`Fetching details for User ID: ${
id: UserId
id
}`);
}
const
const myUserId: UserId
myUserId
=
function UserId(id: string): UserId
UserId
("user-abc-123");
const
const myProductId: ProductId
myProductId
=
function ProductId(id: string): ProductId
ProductId
("prod-xyz-789");
function fetchUserDetails(id: UserId): void
fetchUserDetails
(
const myUserId: UserId
myUserId
);
function fetchUserDetails(id: UserId): void
fetchUserDetails
(myProductId);
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
// Declare unique symbols (they only exist at compile-time)
// We DON'T export these, keeping them private to this file.
declare const
const userIdBrand: typeof userIdBrand
userIdBrand
: unique symbol;
declare const
const productIdBrand: typeof productIdBrand
productIdBrand
: unique symbol;
// Define branded types using the symbols as computed keys
export type
type UserId = string & {
[userIdBrand]: "UserId";
}
UserId
= string & { [
const userIdBrand: typeof userIdBrand
userIdBrand
]: "UserId" };
export type
type ProductId = string & {
[productIdBrand]: "ProductId";
}
ProductId
= string & { [
const productIdBrand: typeof productIdBrand
productIdBrand
]: "ProductId" };
// Create exported "constructor" functions
export function
function UserId(id: string): UserId
UserId
(
id: string
id
: string):
type UserId = string & {
[userIdBrand]: "UserId";
}
UserId
{
return
id: string
id
as
type UserId = string & {
[userIdBrand]: "UserId";
}
UserId
;
}
export function
function ProductId(id: string): ProductId
ProductId
(
id: string
id
: string):
type ProductId = string & {
[productIdBrand]: "ProductId";
}
ProductId
{
return
id: string
id
as
type ProductId = string & {
[productIdBrand]: "ProductId";
}
ProductId
;
}
// --- In another file, we import our types and constructors ---
// import { UserId, ProductId } from './types';
function
function fetchUserDetails(id: UserId): void
fetchUserDetails
(
id: UserId
id
:
type UserId = string & {
[userIdBrand]: "UserId";
}
UserId
) {
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(new Error('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
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = 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(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void

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()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(`Fetching details for User ID: ${
id: UserId
id
}`);
}
const
const myUserId: UserId
myUserId
=
function UserId(id: string): UserId
UserId
("user-def-456");
const
const myProductId: ProductId
myProductId
=
function ProductId(id: string): ProductId
ProductId
("prod-uvw-012");
function fetchUserDetails(id: UserId): void
fetchUserDetails
(
const myUserId: UserId
myUserId
);
function fetchUserDetails(id: UserId): void
fetchUserDetails
(myProductId);
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.

TAGS:

TypeScript
How branded Types Work in TypeScript