Improve Runtime Type Safety with Branded Types in TypeScript

Matías Hernández
author
Matías Hernández
Purple and Pink Diamond on Blue Background by Rostislav Uzunov on Pexels

You already know that in order to get more reliability and safety in your codebase you can use Typescript, but even with it there is room for improvement!

One concept that can increase that reliability is Branded Types.

They provide a way to create deeper specificity and uniqueness for modeling your data beyond that basic types primitives.

In this article we’ll explore what branded types are, how to used, some use cases for them and a challenge to ensure the learnings.

The Problem

Typescript helps to ensure that the correct data is passed through your application flow, but more than often, the data is not specific enough. Let’s imagine the following scenario: an application where the users are owners of several other data structure

type User = {
id: string
name: string
}
type Post = {
id: string
ownerId: string;
comments: Comments[]
}
type Comments = {
id: string
timestamp: string
body: string
authorId: string
}

This define the relation between a User type and the Post where a Post can have many Comments that are written by a User.

The problem here is that the property that identifies each object: User["id"], Post["id"] and Comments["id"] are actually just strings therefore, there is no way for Typescript to catch if your are passing the wrong data, for Typescript, they are all the same and interchangeable.

async function getCommentsForPost(postId: string, authorId: string) {
const response = await api.get(`/author/${authorId}/posts/${postId}/comments`)
return response.data
}
const comments = await getCommentsForPost(user.id,post.id) // This is ok for Typescript

The above snippet shows a function that receives two arguments postId and authorId both of them are just strings. But there is an error here, did you catch it?. When calling the getCommentsForPost function I passed the arguments in the wrong order, but for Typescript is all the same, so no error.

That function call will have the wrong response on runtime, but how can you allow Typescript to catch this errors? We need a way to specify the different types of ID.

Branded Types?

A pattern evolved from the community that is commonly used for this case are Branded Types. The idea is to create a more specific and unique data type with greater clarity and specificity, this is accomplished by adding attributes or labels to an existing type to create a new, more specific type.

Brands can be added to a type using a union of the base type and an object literal with a branded property. For example:

type Brand<K, T> = K & { __brand: T }
type UserID = Brand<string, "UserId">
type PostID = Brand<string, "PostId">

This creates a new type called UserID, which is associated with the UserId brand. An actual variable prefixed with this brand type must match the specific type to be used, let’s rewrite the previous example using the new Brand helper

type UserID = Brand<string, "UserId">
type PostID = Brand<string, "PostId">
type CommentID = Brand<string, "CommentId">
type User = {
id: UserID;
name: string
}
type Post = {
id: PostID;
ownerId: string;
comments: Comments[]
}
type Comments = {
id: CommentID
timestamp: string
body: string
authorId: UserID
}
async function getCommentsForPost(postId: PostID, authorId: UserID) {
const response = await api.get(`/author/${authorId}/posts/${postId}/comments`)
return response.data
}
const comments = await getCommentsForPost(user.id,post.id) // ❌ This fails since `user.id` is of type UserID and no PostID as expected
// ^Argument of type 'UserID' is not assignable to parameter of type 'PostID'.
// Type 'UserID' is not assignable to type '{ __brand: "PostId"; }'.
// Types of property '__brand' are incompatible.
// Type '"UserId"' is not assignable to type '"PostId"'.

Check this example in the Typescript playground

This is a good solution to the current problem, but is has some issues or downsides:

  • the __brand property used to “tag” the type is a “build-time” only property.
  • The __brand property is still shown through Intellisense, this can trigger issues if some developer try to use it, since it will not be present on runtime.
  • Is possible to duplicate branded types since there are no securities on __brand property.

A better Branded Type

The previous implementation is a naive interpretation of a branded type. To avoid the listed downside you should use a stronger implementation of the Brand utility type.

  • Instead of using a hard-coded property named __brand you can use a computed property key.
  • To avoid duplication of the mentioned key you can use a unique symbol
  • Also, write the Brand utility into its own file to prevent read access to the property.
declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }
export type Branded<T, B> = T & Brand<B>

check this playground link to see how this work

Why are Branded Types useful?

Branded types can bring several benefits over using primitive types, such as:

  • Clarity: It offers a more expressive and clarity about the intended use of the variables. For example, a "Username" branded type can ensure that the variable contains only valid usernames, avoiding issues related to invalid characters or lengths.
  • Safety and correctness: They can help prevent issues by making it easier to reason about the code and catch errors related to type incompatibility or mismatch.
  • Maintainability: They can make codebases more maintainable by reducing ambiguity and confusion and thereby making it easier for others to understand the code. By using branded types, developers can communicate their intent more clearly and avoid misunderstandings or misuses of variables. Additionally, branded types can make refactoring easier by providing a clear distinction between different types of data and their respective uses.

Use Cases

Branded types can be used in many scenarios. Here are a few examples:

Custom validation

They can help with the creation of validation functions to ensure that data from the user comports to a standard or desired format. For instance, brands could be used to validate email addresses as being in a proper format:

type EmailAddress = Brand<string, "EmailAddress">
function validEmail(email: string): EmailAddress {
// email address validation logic here
return email as EmailAddres;
}

If at any point in time the email is not of the proper brand or the input is unclear, the user will receive a failure message.

Domain modeling

Branded types excel in domains modeling that can be translated into a more expressive coding experience overall. For instance, a car manufacturing line could use branded types for different features or kinds of cars:

type CarBrand = Brand<string, "CarBrand">
type EngineType = Brand<string, "EngineType">
type CarModel = Brand<string, "CarModel">
type CarColor = Brand<string, "CarColor">

With this approach, the type checker can now enforce better type safety:

function createCar(carBrand: CarBrand, carModel: CarModel, engineType: EngineType, color: CarColor): Car {
// ...
}
const car = createCar("Toyota", "Corolla", "Diesel", "Red") // Error:
// "Diesel" is not of type "EngineType"

APIs responses and Requests

API endpoints can use brands to help customize the responses and requests from API calls. Branded or labeled types work well in this context because the labeling comes from the API providers. In a hypothetical code example, here, we will use a brand with a specific API to differentiate between successful and failed API calls:

type ApiSuccess<T> = T & { __apiSuccessBrand: true }
type ApiFailure = {
code: number;
message: string;
error: Error;
} & { __apiFailureBrand: true };
type ApiResponse<T> = ApiSuccess<T> | ApiFailure;

Now you can use this response type, avoiding misconceptions:

const response: ApiResponse<string> = await fetchSomeEndpoint();
if (isApiSuccess(response)) {
// handle success response
}
if (isApiFailure(response)) {
// log error message
}

A Challenge for you

The best way to learn something related to code is to actually building or solving something related to the topic.

Your task is to create a new branded type for a person's age, a function createAge which takes a number input and returns it as Age and a function named getBirthYear that accepts an Age and return a number.

Age is not just any number, it should belong to the interval [0, 125] (inclusive). If the input does not belong to this interval, the function createAge should throw an error indicating that the input is not within the valid range.

/** You can work on this challenge directly in the typescript playground: https://tsplay.dev/WzxBRN*/
/** For this challenge you need to create the Branded utility type */
type Age = Branded<never, "Age">; // Replace the never with the corresponding primitive type
/**
* 🏋️‍♀️ Fill the function createAge, it should accept a number as input and return a branded Age
**/
function createAge() {
// Perform logic and return Age
}
/**
* 🏋️‍♀️ This function should accept a branded Age type and return a number
*/
function getBirthYear() {
// Perform logic and return Age
}
/** Usage **/
const myAge = createAge(36); // Should be ok
const birthYear = getBirthYear(myAge) // Should be ok
const birthYear2 = getBirthYear(36) // This should show an error
/** Type Tests */
import type { Equal, Expect } from '@type-challenges/utils'
type cases = [
Expect<Equal<typeof myAge, Age>>,
Expect<Equal<typeof birthYear, number>>,
Expect<Equal<Parameters<typeof createAge>, [number] >>,
Expect<Equal<Parameters<typeof getBirthYear>, [Age] >>
]

You can find the solution in this playground link

Conclusion

Branded types are powerful features of TypeScript that can help enhance the type safety, maintainability, and clarity of code. They can provide better control over the shape of data, offer more expressive types, and enable compile-time safety checks that reduce debugging time and prevent runtime errors. By using branded types judiciously, you can create more efficient, scalable, and safer TypeScript projects.