dino.opij.ac

Branding types in TypeScript for fun and profit

A couple of weeks back, I had the opportunity to participate in two insightful sessions centered around secure development. Yes, the term “Secure by Design” was coined. In our current global climate, it’s increasingly important to consider the security of personal, business, and customer data.

While I’m familiar with many of the concepts discussed, revisiting the fundamentals was fun. One example presented by the speaker particularly stood out to me. I might not recall the exact details, such as the specific values or variable names, but the code snippet they showed went something like this:

class Library {
    public Receipt LoanBook(int bookId, int userId) { /* ... */ }
}

var library = new Library(/* ... */);
var receipt = library.LoanBook(1234, 4567);

At first, this seems perfectly fine. But fast-forward a few months or years, and you might find the code evolving into something like this:

var library = new Library(/* ... */);
// ... some other code ...

var id1 = LookupOne();
var id2 = LookupTwo();

// ... more things happen in between ...
var receipt = library.LoanBook(id1, id2);

While the example might seem a bit far-fetched, it quite clearly illustrates primitive obsession. This anti-pattern is characterized by obsessive over-reliance on primitive data types, and in my experience, often leads to non-explicit, fragile code.

I found myself asking: what about TypeScript? I’ve seen many projects exclusively relying on passing strings or numbers all over the place. While that in itself is not “wrong” or “bad”, it surely is a coin-toss.

The solution in C# is straightforward: define distinct types for different IDs.

struct BookId(int id) {}
struct UserId(int id) {}

class Library {
    public Receipt LoanBook(BookId bookId, UserId userId) {
        // ...
    }
}

// Replace int(s) with BookId and UserId where appropriate.
BookId LookupOne() {}
UserId LookupTwo() {} 

var library = new Library(/* ... */)
library.LoanBook(new BookId(1), UserId(5)) // => works

I’d argue that this approach has several advantages:

  • Type Explicitness: It clarifies the intent of your code, making it more understandable to others
  • Control and Flexibility: Being explicit grants more control and allows for extension (say, validation).
  • Early Error Detection: Thanks to the compiler, mistakes can be caught early.

We’ve seen the benefits of type-explicit coding and the power of leveraging the compiler as a first line of defense against mistakes. But, I ask again: What about TypeScript?

Exploring solutions in TypeScript

In TypeScript, a straightforward attempt to solve our earlier issue might involve defining BookId and UserId as distinct number types and using them in a function:

type BookId = number
type UserId = number

function loanBook(bookId: BookId, userId: UserId) {}

This approach appears to improve explicitness with a more descriptive function signature. However, it doesn’t significantly enhance control or type safety. Consider this example:

const bookId: BookId = 1
const userId: UserId = 2
loanBook(userId, bookId) // argument swapping mistake

Despite our best intentions, TypeScript’s type system isn’t enough to prevent a mix-up like this. TypeScript may not have support for structs, but it does offer a concept of “classes”. Can that be any use to us?

class BookId { 
    constructor(readonly id: number) {}
}

class UserId { 
    constructor(readonly id: number) {}
}

loanBook(new BookId(1), new UserId(2))
// => works.

loanBook(new UserId(2), new BookId(1))
// => works...

It appears that no matter what we do, we simply cannot replicate that behaviour of C# - and that is for a good reason: TypeScript is a structurally typed language while C# is a nominally typed language.

In structural typing, the focus is on the shape of the data. Two objects are considered to be of the same type if they have the same structure, regardless of the name given to these types. In the example above, even though BookId and UserId are named differently, structurally, they are identical: both are objects containing an property id with type string.

Therefore, TypeScript treats them as interchangeable. Contrast this to C# which has a nominal type system, each type is unique regardless of the data it holds. You simply cannot assign across types.

Let’s play by the rulebook and ensure that the types are distinguishable:

class BookId { 
    constructor(readonly bookId: number) {}
}

class UserId {
    constructor(readonly userId: number) {}
}

loanBook(new BookId(1), new UserId(2))
// => works

loanBook(new UserId(2), new BookId(1))
// => Argument of type UserId is not assignable to parameter of type BookId
//    Property bookId is missing in type UserId but required in type BookId

While this approach solves a lot of the problems we encountered earlier, it requires the person writing it for the first time to always have unique property names for the thing they are modelling.

For instance, imagine that you are want to distinguish between AdministratorUserId and UserId - someone might make the mistake of using the property name userId for both. For smaller projects, this might not be an issue, but once your project grows, you need to keep track of the property names one way or another. The reason we’re interested in mimicking nominal types in the first place is to decrease the surface area for mistakes such as this.

This approach has another disadvantage as well: TypeScript is just a superset of JavaScript, all the code we write will eventually be transpiled to JavaScript. This output will contain the class definitions for whatever class we make.

Our end goal is to enhance the developer experience, and by doing that, also ensuring our users that whatever code we write will benefit them as well. This doesn’t to that.

Lastly, we end up repeating ourselves. I’d argue that it does not feel intuitive either:

function loanBook(bookId: BookId, userId: UserId) {
    somethingUsingBookId(bookId.bookId)
    somethingUsingUserId(userId.userId)
}

Faking nominal types by branding

From all the explored options discussed earlier, I do appreciate the type-oriented option a lot. Throughout this article, we’ve repeatedly bumped into issues that are related to the structural nature of TypeScript. From what our eyes see and our minds can process: there is a difference between the a BookId and UserId, but how can we tell that to a compiler that only deals with structural types compiler? We create a structure that can be distinguished using a concept known as branding.

type BrandedBookId = number & { __brand__?: "BookId" }
type BrandedUserId = number & { __brand__?: "UserId" }

// can be generalized as:
type Brand<T, B> = T & { __brand__?: B }

At first, this doesn’t seem to be very intuitive. But it does make the two types distinguishable via __brand__. What we’ve essentially done is to “brand” the number for BookId as "BookId". __brand__ is optional because we want to be able to assign a primitive number to whatever variable or function that accepts our type.

Before proceeding, let’s define some type tests for the types we’re about to use. You can use a library to do this for you, but I find that the @ts-expect-error directive works just fine for testing types.

Here’s the setup for our tests:

type Brand<T, B> = T & { __brand__?: B }

type BookId = Brand<number, "BookId">
type UserId = Brand<number, "UserId">

const bookId: BookId = 12
const userId: UserId = 34

function acceptsBookIdAndThenUserIdArgs(a: BookId, b: UserId): any {}

Next, using the setup, we can define some tests.

// FAIL
// @ts-expect-error
type brand_name_should_have_string_constraint = Brand<number, number>

// PASS
// @ts-expect-error
const string_should_not_be_assignable_to_book_id: BookId = "foo"

// PASS
// @ts-expect-error
const user_id_should_not_be_assignable_to_book_id: BookId = userId

// FAIL
// @ts-expect-error
const constant_book_id_should_not_be_equatable_to_constant_user_id 
    = bookId === userId

// PASS
// @ts-expect-error
const user_id_argument_should_not_be_assignable_to_book_id 
    = acceptsBookIdAndThenUserIdArgs(userId, bookId)

Let’s start off by making brand_name_should_have_string_constraint pass - we want our fellow developers to be consistent when branding a type.

type Brand<T, B>                = T & { __brand__?: B } // before
type Brand<T, B extends string> = T & { __brand__?: B } // after

The next test that fails is:

constant_book_id_should_not_be_equatable_to_constant_user_id
const bookId: BookId = 12
const userId: UserId = 34

// FAIL
// @ts-expect-error
const constant_book_id_should_not_be_equatable_to_constant_user_id 
    = bookId === userId

This one is a bit more tricky though. The Brand type we’ve created essentially adds a optional __brand__ to a base type (in our case, number). Just look at this expanded type:

const bookId: number & { __brand__?: "BookId" } = 42

This branding doesn’t change the underlying structural nature of the types. Both BookId and UserId are still, at their core, numbers. Hence, the type checker sees no type error in comparing these two values!

Some might argue that we already have accomplished what we were looking to solve in the first place: Passing two arguments that are swapped is no longer possible, and for many of you, this is good enough. But we can do better.

Let’s go deeper

Keep in mind, so far, we’ve been dabbling around on the type-level. No JavaScript code is emitted, because JavaScript does not have any types. This is a huge win for clients, because we’re not sending any extra bytes with class definitions and alike.

Let’s make the __brand__ property non-optional. By doing this we’re forcing all usages of Brand<T, B> to also include the __brand__ property when initialized.

type Brand<T, B extends string> = T & { __brand__?: B } // before
type Brand<T, B extends string> = T & { __brand__: B }  // after

// Now we need to do this:
type BookId = Brand<number, "BookId">
const bookId: BookId = 12 as 12 & { __brand__: "BookId" }

This is enough to make the types distinguishable. All the tests we wrote earlier are now passing, but with a significant trade off. We have to explicitly cast the values to include the __brand__ property. This is not very ergonomic.

I’ll be honest, in my first attempt at solving this problem, I took the naivë approach of creating a helper function to cast the number to a branded type.

It works, but it doesn’t feel very elegant, nor does it scale well:

function createBookId(id: number): BookId {
    return id as BookId
}

We need to take a step back and think about how we can make this more generic. Ideally, we would just like to call a factory function that creates the correct type for us:


const createBookId = createBranded<BookId>()
const createOtherId = createBranded<"...">()

// createBookId(1234) => 1234 but also branded as BookId

Let’s use the type system to our advantage to store both the base type (number) and the brand name ("BookId") as generic parameters.

declare const __base__: unique symbol
declare const __brand__: unique symbol

type Brand<Type, Identifier> = {
    [__base__]: Type
    [__brand__]: Identifier
}

We now have a Brand type that uses unique symbols as property keys. The typing system ensures us that the properties cannot be accidentally accessed or modified.

Now, to get back to where we were previously, we need another type that denotes that something is “branded”:

type Branded<Type, Identifier> = Type & Brand<T, Identifier>

// Branded<number, "BookId">
// => number & { [__base__]: number; [__brand__]: "BookId" }

We can even extract the actual type from the Branded type now:

type BrandedType<T> = T extends Brand<infer Type, any> ? Type : never

type BookId = Branded<number, "BookId">
// BrandedType<BookId>
// => number

With these pieces in place, the compiler now carries both halves of the information: the runtime shape (number) and the nominal tag ("BookId"). That lets us write factories that are completely zero-cost at runtime but still give us great type safety.

To make the ergonomics nice, we’ll expose a tiny factory that brands a value. The trick is to constrain the factory’s generic T so it must be a Branded<…>, and then infer the underlying base type from it:

export function createBrand<
    // constrain T to be a branded type
    T extends Branded<Type, any>,
    // extract the base type (number in our case)
    Type = BrandedType<T>
>() {
    // cast value to branded type T
    // If we don't do this, the resulting type would be "number", we want "BookId"!
    return (value: Type) => value as unknown as T
}

With this utility in place, we can now create branded types easily:

type BookId = Branded<number, "BookId">
const createBookId = createBrand<BookId>()
  const someBookId = createBookId(12)

// Further simplification:
export type  BookId = Branded<number, "BookId">
export const BookId = createBrand<BookId>()

// Use BookId elsewhere:
import { BookId } from "path/to/brand"
const x = BookId(12) // => BookId with value 12

Conclusion

It took a few type-level gymnastics to get here, and this approach might not be for everyone. The added type complexity can feel unnecessary, but once you get comfortable with the pattern, branding can be a powerful tool.

Since the brand exists only in the type system, there’s no runtime cost, just a compile-time guarentee that TypeScript enforces for you. If you ever need runtime validation, you can always layer a validator on top of the same factory without changing how you use it.

Nominal typing helps prevent those easy-to-miss bugs that come from mixing up primitive values. TypeScript’s structural type system doesn’t make it straightforward, but until we get true nominal types (which is highly unlikely), branding can be a practical workaround.

← Go back