Branded Types in TypeScript

Branded Types in TypeScript

I actually needed them for a real use case.

Introduction

When I built Jotai from scratch, I needed to use branded types.

In this post, I wanna go over what they are and when they're useful.

It shouldn't be a long one.

Typing in TypeScript

type UserID = string;
type PostID = string;

function getUser(id: UserID) {
  /* ... */
}
function getPost(id: PostID) {
  /* ... */
}

// This compiles but is logically wrong
const postId: PostID = "123";
getUser(postId); // TypeScript doesn't catch this error

TypeScript uses structural typing by default. Types are considered compatible if they have the same structure, regardless of their names. This can lead to bugs where we accidentally pass the wrong type of ID or value, even though they're structurally identical (whether primitives or objects).

Many other languages like Java, C#, and Rust use nominal typing. Types are only compatible if they have the same name and structure. This prevents accidentally using one type where another is expected, even if they're structurally the same.

Branded types

We can simulate nominal typing in TypeScript using branded types (also called tagged types).

Here's how:

type UserID = string & { __brand: "UserID" };
type PostID = string & { __brand: "PostID" };

function getUser(id: UserID) {
  /* ... */
}
function getPost(id: PostID) {
  /* ... */
}

// This now fails to compile
const postId: PostID = "123" as PostID;
getUser(postId); // Type error!

The __brand property is a phantom type. It exists only at compile time and has no runtime representation. By intersecting our base type with a unique brand, we create distinct types that TypeScript treats as incompatible, even though they're structurally identical at runtime.

This pattern is particularly useful when working with primitive values that need to be kept distinct.

In Jotai's case, we use it to ensure type safety when storing atom values in a Map:

type PrimitiveAtom<TValue> = {
  type: "primitive";
  __brand: TValue;
};

const atomPrimitiveValues = new Map<AnyPrimitiveAtom, unknown>();

// The brand ensures TypeScript knows which value type each atom holds
// This is a AnyPrimitiveAtom<number>
const numberAtom = atom(1);
// This is a AnyPrimitiveAtom<string>
const stringAtom = atom("hello");

// How we get the value from the atom
// Because you can't pass geneics to Map the same way you can with a generic type parameter
// We need to cast the value we get to the value inside the ato
// So we can still keep things type safe
function getAtomValue<TValue>(atom: Atom<TValue>): TValue {
  if (atom.type === "primitive") {
    // The type system knows that for a PrimitiveAtom<number>,
    // the value must be a number because of how we stored it
    return atomPrimitiveValues.get(atom) as TValue;
  }
  // ... derived atom handling
}

Without the brand, TypeScript wouldn't be able to track which value type belongs to each atom, since the Map values are stored separately from the atom objects themselves.

Branded types are useful for ensuring type safety when the structure of the data is the same but it's inherently different.

A note on Phantom types

The pattern originated in the functional programming world, where phantom types were first used in languages like Haskell to add compile-time constraints without runtime overhead. The technique was later adopted by the TypeScript community as a way to achieve nominal typing when needed.