Published on

Stricter Types in TypeScript with Brands

TypeScript's types can help ensure you pass the correct data to a function, but sometimes they may not be specific enough. For example, imagine you're building a messaging app in which a user could be a participant in multiple conversations, each of which contains several messages. You might model the data using types like these:

type User = {
  id: number
  name: string
}

type Conversation = {
  id: number
  participantIds: number[]
  messages: Message[]
}

type Message = {
  id: number
  timestamp: string
  body: string
  authorId: number
}

The problem here is that User['id'], Conversation['id'], and Message['id'] are all of type number and could be treated by TypeScript as interchangeable. Imagine a function that fetches additional details for a conversation given a conversation ID, but you accidentally pass a user ID instead. TypeScript will happily allow this, and you may not realize your mistake until you run your app and see some unexpected behavior.

async function getConversationDetails(conversationId: number) {
  const response = await api.get(`/conversation/${conversationId}/details`)
  return response.data
}

const details = await getConversationDetails(user.id) // Looks okay to TypeScript!

A similar source of confusion is with functions that accept an interval. Should it be defined in seconds or milliseconds? The TypeScript function signature only tells us it needs to be a number.

We need a way to tell TypeScript that these ID types aren't interchangeable.

Branded Types

A standard TypeScript solution to this problem is an approach called "branded types." The idea is to attach a unique "brand" to a type to distinguish it from similar types, usually as a hidden property:

type UserId = number & { __brand: 'UserId' }
type ConversationId = number & { __brand: 'ConversationId' }

type User = {
  id: UserId
  name: string
}

type Conversation = {
  id: ConversationId
  participantIds: UserId[]
  messages: Message[]
}

This forces TypeScript to treat UserId and ConversationId as different types because the types of their __brand properties are different.

async function getConversationDetails(conversationId: ConversationId) {
  const response = await api.get(`/conversation/${conversationId}/details`)
  return response.data
}

const details = await getConversationDetails(user.id)
                                          // ^^^^^^^ Error!
                                          // Argument of type 'UserId' is not
                                          // assignable to parameter of type
                                          // 'ConversationId'.

This improvement solves the immediate problem, but it has a few downsides.

  • The __brand property won't exist at runtime but will still appear in Intellisense suggestions. An inexperienced developer may attempt to use it as a tagged union, such as if (item.__brand === 'UserId').
  • It's possible to accidentally define a duplicate brand if another file or library uses the same name and the same __brand property. When that happens, TypeScript's structural typing will treat the two types as the same, even if they refer to different resources.
  • Passing a hard-coded value to anything that expects a branded type requires an extra type assertion (for example, 250 as Milliseconds). This increased verbosity may help improve readability, but the type assertion could lead to bugs.

Let's see how we can improve on these issues.

Improving our branded types

The first improvement we can make is to create a reusable type for brands. We'll build on this type over the next few steps.

type Brand<B> = { __brand: B }
type Branded<Base, B> = Base & Brand<B>

type UserId = Branded<number, 'UserId'>
type ConversationId = Branded<number, 'ConversationId'>

Now we can start addressing the issues from above. We can handle the first two issues together. Instead of using the hard-coded __brand property, we'll use a computed property defined as a unique symbol. If we also move these to a separate file, it will prevent other developers from incorrectly attempting to read the value of the __brand property. The unique symbol will prevent collisions with other libraries' brand implementations.

declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }

export type Branded<Base, B> = Base & Brand<B>

Next, we'll want to create a few helper functions to make working with these branded types easier. I'll create a set of helpers to do three things:

  • check if we can treat an instance of the base type as the branded type
  • assert that we can treat it as the branded type
  • safely cast the value to the branded type

Your requirements may differ from mine, but you can use these helpers as a starting point.

Getting access to the base type

To create these helpers, we'll first need to figure out the base type given a branded type. To do that, we'll add another property to the Brand type and create a new conditional type:

declare const __brand: unique symbol
declare const __base: unique symbol

type Brand<Base, B> = {
  [__brand]: B
  [__base]: Base
}

type Branded<Base, B> = Base & Brand<Base, B>
type BrandBase<T> = T extends Brand<infer Base, any> ? Base : never

BrandBase<T> can take a branded type and return its unbranded base type. For example,

type UserId = Branded<number, 'UserId'>
type UserIdBase = BrandBase<UserId> // type UserIdBase = number

Now we can define the contract for our helper functions. I'll create a factory function to keep the helpers organized (especially when working with multiple branded types in the same file).

type BrandBuilder<T extends Branded<Base, any>, Base = BrandBase<T>> = {
  check: (value: Base) => value is T
  assert: (value: Base) => asserts value is T
  from: (value: Base) => T
}

Depending on the nature of a branded type, we may need to define some criteria to be evaluated in the check and assert helpers. It isn't necessary in our UserId/ConversationId/MessageId example (where any number can be a valid ID), but there are other cases where it would be helpful. We could create Second and Millisecond branded types to avoid confusion when defining intervals and durations. In this case, we may also want to ensure that the value is non-negative. We'll have our factory function accept an optional validate function to handle that.

type BrandBuilderOptions<Base> = {
  validate?: (value: Base) => boolean | string
}

Here, I chose to have the validate function return either a boolean or a string so we can define a custom error message for the assert helper. Now we can finally create the factory function.

function brand<T extends Branded<Base, any>, Base = BrandBase<T>>({
  validate = () => true,
}: BrandBuilderOptions<Base> = {}): BrandBuilder<T, Base> {
  function assertIsBrand(value: Base): asserts value is T {
    const result = validate(value)
    if (typeof result === 'string') {
      throw new Error(result)
    }
    if (result === false) {
      throw new Error(`Invalid value ${value}`)
    }
  }

  return {
    check: (value): value is T => validate(result) === true,
    assert: assertIsBrand,
    from: (value: B) => {
      assertIsBrand(value)
      return value as T
    },
  }
}

Let's see how this works for some of the use cases we've mentioned.

type UserId = Branded<number, 'UserId'>
const UserId = brand<UserId>()

const anId = UserId.from(0) // const anId: UserId

type NonNegative = Branded<number, 'NonNegative'>
const NonNegative = brand<NonNegative>({
  validate: (value) => value >= 0 || 'Value must not be negative',
})

NonNegative.check(0) // true
NonNegative.assert(1) // ok
const negative = NonNegative.from(-1) // Runtime error: "Value must not be negative"

Conclusion

Branded types are a great way to raise possible runtime and logic errors at compile time and make code more readable by replacing general-purpose types with more domain-specific ones. With only a few helper functions, they can be easy to use and give you more confidence in your code.