- 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 asif (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.