Improve Runtime Type Safety with Branded Types in TypeScript
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
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.
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:
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
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 aunique symbol
- Also, write the
Brand
utility into its own file to prevent read access to the property.
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:
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:
With this approach, the type checker can now enforce better type safety:
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:
Now you can use this response type, avoiding misconceptions:
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 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.