Type Branding in Typescript

The Initial Discovery

The other day, while exploring the Zod documentation for some type schema validators for one of my side projects, I stumbled upon something intriguing that didn’t immediately click: branded types.

The feature is described as follows:

Typescript’s type system is structural, meaning that two types that are structurally equivalent are considered the same.
[…]
It can be desirable to simulate nominal typing inside Typescript. This can be achieved with branded types (also known as “opaque types”).

What does this actually mean in practice? Let’s explore this fascinating concept together.

Nominal vs. Structural Typing

When designing a programming language’s type system, creators must define semantic rules that help the compiler determine:

  • When type A is equal to type B (type equivalence)
  • When an object of type A is allowed in a context that expects type B (type compatibility)

This is where the concepts of nominal typing and structural typing come into play. These represent different approaches to answering these fundamental questions and significantly influence how the language behaves. Most languages you use daily fall into one of these categories (with some hybrid exceptions).

Nominal Typing

In a nominal type system, type identity is based on names (or explicit type coercion). Simply put, two types are considered the same only if they are explicitly declared to be the same, regardless of their internal structure. Go exemplifies a nominal type system (for named types).

1
2
3
4
5
type (
// A and B are structurally identical
A string
B string
)

Here’s what this means:

  1. A and B are not equivalent, so variables cannot be assigned between them without explicit casting
  2. A and B are not compatible, so a variable of type A cannot be used as a parameter for a function expecting type B
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var (
a = A("Hello")
b = B("World!")
)

func main() {
var c B
c = b // valid statement: c and b have the same type

c = B(a) // valid statement: c and a have different types, but a can be casted to B

a = b // invalid statement: a and b have different types -> compiler error

func(_ B) {}(a) // invalid statement: type A is not compatible with type B
}

Nominal type systems make it difficult to accidentally confuse different types that may be structurally similar but semantically distinct. This provides stronger abstraction boundaries, which is typically considered essential for statically typed languages. However, it introduces some overhead when verifying structural identity between types. Other languages that adopt nominal typing include Rust, Java, C#, and many others.

Structural Typing

In a structural type system, what matters is the shape of the type. Two types are considered identical if they share the same structure. Typescript is a prime example of a structural type system.

1
2
type A = string;
type B = string;

This means:

  1. A and B are equivalent, so variables can be freely assigned between them
  2. A and B are compatible, so a variable of type A can be used wherever type B is expected
1
2
3
4
5
6
let a: A = "Hello";
let b: B = "World!";

a = b; // valid statement: a and b represents the same type

((_: A) => 1)(b); // valid statement: B is compatible with A

Structural type systems favor flexibility over semantic correctness and are commonly adopted by interpreted languages, where conciseness is valued and type definitions aren’t considered the language’s core feature.

When the Lines Blur

It’s worth noting that the distinction between nominal and structural type systems isn’t always clear-cut. Modern languages often adopt different approaches for different scenarios. For instance, while Go uses a nominal type system, its interface handling leans toward the structural approach - interfaces are satisfied implicitly when a type implements the required methods.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type I interface {
F() string
}

type A struct{}

func (*A) F() string { return "Hello World!" }

func main() {
a := &A{}

var _ I = a // *A can be assigned to var of type I

foo(a) // *A can be assigned as func param of type I
}

func foo(_ I) {}

Adding Nominal Flavor to Typescript

As mentioned earlier, Typescript employs a structural type system at its core, which works excellently most of the time. However, there are situations where you need more precise control over the types flowing through your application, and the structural approach becomes more of a hindrance than a help. In these cases, you need to introduce a touch of nominal typing to your Typescript project.

This is where Type Branding becomes invaluable! This technique leverages the fact that T !== T & { readonly p: unique symbol}. We simply define our custom type as an intersection between the type we want to “brand” and a property with a unique symbol type (which will never be instantiated - we just need the type system to recognize its presence). By convention, this “phantom” property should be named __brand.

1
type BrandedType<T> = T & { readonly __brand: unique symbol };

In the following example, this clever technique allows the render function to accept only values that have been processed by the sanitize function, while rejecting any other string that might be unsafe and contain injections at compile-time. Please note: don’t use anything like this for actual HTML sanitization - focus on understanding the type mechanics! 🫠

1
2
3
4
5
6
7
8
9
10
11
12
13
type SafeHtml = BrandedType<string>;

function render(html: SafeHtml) {
document.body.innerHTML = html;
}

function sanitize(input: string): SafeHtml {
return input.replace(/</g, "&lt;") as SafeHtml;
}

const unsafe = "<script>alert(1)</script>";
render(sanitize(unsafe)); // correct statement: SafeHtml can be passed to `render`
render(unsafe); // incorrect statement: string is not compatible with SafeHtml

Sometimes, developers define the __brand property as a string literal. This approach helps IDEs provide users with more context about the semantic meaning of the type during inspection (via hovering, for those who haven’t discovered the joy of Vim yet). The downside is potential collisions if different branded types accidentally use the same string, but this is typically an acceptable risk.

1
2
type BrandedType<T, I extends string> = T & { readonly __brand: I };
type A = BrandedType<string, "BrandedString">;

Type branding was new to me, but it’s apparently well-established in the community. The open-source ecosystem has produced numerous specialized libraries: ts-brand, effect-ts, and @coderspirit/nominal are just a few examples I discovered with a quick search. If you want to master this technique, these resources are excellent starting points!

Bringing It All Together

With this understanding, it becomes clear how Zod’s branding schema actually works!

Type branding represents a powerful technique for bringing nominal typing benefits to Typescript’s structural world. It allows us to create stronger type safety boundaries while maintaining the flexibility that makes Typescript so appealing. Whether you’re building APIs, handling user input, or managing complex domain models, branded types can help you catch errors at compile-time that might otherwise slip through to runtime.

So go ahead - transform your structural world into a more robust, nominally-flavored experience!


Thanks to Antonio Pitasi for reviewing this post while in draft <3.