← Back to Blog
TypeScript

Better type-safety in TypeScript with Branded Types

Structural vs nominal type systems in TypeScript, and how Branded Types bring type-level uniqueness (even to primitive types) for stronger type-safety.

📅 ✍️ Antoine Coulon
typescriptbranded-typestype-safetytype-systemeffect

Are two types that share exactly the same shape the same type? For TypeScript, the answer is yes, and that’s precisely what becomes a problem the moment you try to model distinct but structurally identical concepts. A UserId and an OrderId are both strings; by default, nothing stops you from passing one where the other is expected. Branded Types are the technique that restores this distinction at the compiler level, without changing the value you manipulate at runtime.

To understand where this limitation comes from and how to lift it, we first need to revisit how TypeScript decides that two types are equivalent.

Structural type system versus nominal type system

TypeScript belongs to the family of languages built on a structural type system, unlike Java, for example, which uses a nominal type system. The difference is fundamental and shapes everything else.

TypeScript’s structural typing

In a structural type system, the equivalence of two types A and B is determined structurally: A and B are considered equal as soon as they share the same type structure. The name they were given is irrelevant.

So, as far as TypeScript is concerned, two types like type Employee = { name: string } and type Visitor = { name: string } are perfectly equivalent: an Employee can be used anywhere a Visitor is expected, and vice versa.

The nominal typing of other languages

A nominal type system, on the other hand, bases type equivalence on the naming of the types themselves. It’s the model adopted by many languages you know: C++, Rust, Java, C#, and so on.

In Java, for example, two classes like class Cat { public void walk() {} } and class Dog { public void walk() {} } do share the same structure (a walk method) yet they will never be considered equivalent. Their identity comes from their name, not their shape.

The limit of purely structural typing

The trouble with purely structural typing is this: it becomes impossible to uniquely represent two types that have the same structure (or the same primitive type) but carry different meanings and must absolutely not be interchangeable.

This is an extremely common case. A user identifier and an order identifier are both string. A distance in meters and a distance in feet are both number. An email and a password are both string. The compiler, however, sees only the structure: to it, these are perfectly substitutable types. All the business semantics that distinguish them is lost, and with it the ability to detect at compile time that you’ve swapped two values.

The good news is that with TypeScript you can reproduce one of the key traits of a nominal type system, and even go a step further on primitive types, thanks to Branded Types.

What is a Branded Type?

A Branded Type is a type to which you attach a unique identifier at the type-level, called a brand, whose sole purpose is to distinguish two types that would otherwise have the same structure.

The benefit is twofold.

First, the brand guarantees type-level uniqueness even though the underlying type remains structurally equivalent. Our Employee type can no longer be used in place of a Visitor, even though both share exactly the same properties. Here we recover the behavior of a nominal system, on demand, exactly where we need it.

Second, and this is the big advantage specific to this technique, you can apply a brand to primitive types like number or string. A UserEmail type can no longer, by default, be used in place of a UserId type, even though both are essentially string. That is exactly the guarantee that bare structural typing lacks.

There is a trade-off to be aware of, though: by construction, Branded Types only offer type-level guarantees. They can therefore be bypassed by a type cast (as). But this limit is easy to lift: by systematically going through a construction function, you can tie validation to the brand’s creation and thus guarantee safety all the way to runtime.

Hand-rolled Branded Types in TypeScript

The most direct construction relies on an intersection with an object carrying a __brand property typed as a unique symbol. It’s this symbol, unique by definition, that makes each brand incompatible with all the others.

/** Antoine COULON, an introduction to Branded Types with TypeScript */

// Building Branded Types manually with a unique symbol to
// guarantee type-level uniqueness. Works with primitive types!
type UserEmail = string & { readonly __brand: unique symbol };
type UserId = string & { readonly __brand: unique symbol };

// Works with objects too
type User = {
  email: UserEmail;
  id: UserId;
} & { readonly __brand: unique symbol };

// Then you can use these types in a perfectly classic way, but
// with more guarantees
const userEmail: UserEmail = "some@email.com";
//    ^^^^^^^^^^ Type 'string' is not assignable to type 'UserEmail'.

// Building the Branded Type
function makeUserId(id: string): UserId {
  // Opportunity to validate the data at construction time
  // before returning the UserId Branded Type
  return id as UserId;
}

// Using the Branded Type
function createUser(id: UserId) {
  //
}

// Although "123" is a string, the compiler clearly distinguishes it from a 'UserId'
createUser("123");
//         ^^^  Argument of type 'string' is not assignable to parameter of type 'UserId'.

const userId = makeUserId("123");
createUser(userId);
//         ^^^^^^ OK

Several things in this example are worth highlighting. The direct assignment const userEmail: UserEmail = "some@email.com" fails: a raw string is not assignable to the branded type, which forces you through a controlled path. The makeUserId function plays exactly that role of a checkpoint: it’s the ideal place to validate the data (check a format, a length, a membership) before performing the as UserId cast that stamps on the brand. Finally, the call createUser("123") is rejected by the compiler even though "123" is a perfectly valid string: that’s exactly the guarantee we were after, the impossibility of confusing an arbitrary string with a user identifier.

Branded Types with Effect

Implementing these brands by hand stays a bit verbose, especially as soon as you want to back them with systematic runtime validation. The Effect library provides a dedicated Brand module that formalizes both usages: with or without runtime validation.

/** Antoine COULON, an introduction to Branded Types with TypeScript + Effect */

import { Brand } from "effect";

type Int = number & Brand.Brand<"Int">;

// "refined" lets you create a Branded Type with runtime validation
const Int = Brand.refined<Int>(
  (n) => Number.isInteger(n), // Runtime validation
  (n) => Brand.error(`Expected ${n} to be an integer`) // Error on mismatch
);

// Creating an "Int" Branded Type whose value is indeed an integer
const X: Int = Int(3);

// Attempting to create one from a float, which will throw an error
const Y: Int = Int(3.14);

// You can also create Branded Types without runtime validation
type UserIdentifier = number & Brand.Brand<"UserIdentifier">;
const UserIdentifier = Brand.nominal<UserIdentifier>();

Two constructors coexist here. Brand.refined ties runtime validation to the type: the Int constructor only accepts a value if it satisfies the Number.isInteger predicate, and raises an explicit error otherwise. This is what closes the gap mentioned earlier: an Int is no longer just a number labeled at the type-level, it’s a number that has been verified at construction time to actually be an integer. Conversely, Brand.nominal creates a purely nominal brand, with no runtime cost: here you only want to distinguish two types at the compiler level, like UserIdentifier, which can no longer be confused with an ordinary number.

Note also the use of Brand.Brand<"Int">: Effect relies on a string literal as the brand, rather than a unique symbol. The mechanics differ, but the goal stays the same: giving a type a unique identity at the level of the type system.

Conclusion

TypeScript’s structural typing is a strength: it makes the language flexible and cuts down on ceremony. But that flexibility has a downside: the inability to distinguish two concepts that share the same shape. Branded Types fill precisely this gap by reintroducing, on demand, the uniqueness of a nominal type system, all the way down to primitive types.

The rule to remember is simple: a brand alone only protects at compile time, and remains bypassable with a cast. Its true value emerges when you back it with a construction function that validates the data, manually or via Effect’s Brand.refined. You then get a guarantee that runs from the type-level all the way to runtime: not only does the compiler prevent confusing a UserId with a UserEmail, but you also know that any value carrying the brand has actually passed the check you imposed on it.