Opaque / Branded Types in Typescript

tiagof

Tiago Ferreira

Posted on September 28, 2022

Opaque / Branded Types in Typescript

Typescript has amassed quite the following as one of the top ten most popular programming languages over the last several years. And for good reason! It has greatly accelerated development by providing compile-time guarantees that supports a greater development velocity while maintaining a high bar of quality.

As a quick overview, Typescript provides compile-time type checking in order to prevent common datatype bugs (among other things ๐Ÿ˜‰). It has several native types defined that you can annotate variables with. These range from number, to string, to array/tuple definitions, and more. It even supports more complex types with unions, intersections, and string literals that can allow for incredibly expressive types to reflect the data that you are working with.

However, number or string isn't always specific enough. Sometimes our code specifically needs a value between 0-1 or only a valid email should be used.

What's wrong with just using the general type?

Although more general types like number or string can suffice in terms of general compile-time checks, they fail to provide checks for more nuanced cases. Take for example a function that requires a percentage as an input:

/**
* Sets the alpha value for an image.
* @param percentage - a number between 0 and 1
*/
function setAlpha(percentage: number) { ... }
Enter fullscreen mode Exit fullscreen mode

In this case, we want a number type passed in, but specifically a number between 0 and 1. However, this can cause several potential problems:

setAlpha(0.5) // Correct
setAlpha(-0.5) // Runtime Error: Invalid Percentage - Typescript doesn't catch this
setAlpha(2.3) // Runtime Error: Invalid Percentage - Typescript doesn't catch this
setAlpha('abc') // Build Error: string instead of number - Typescript does catch this
Enter fullscreen mode Exit fullscreen mode

In classical Javascript fashion, one would usually say "add a runtime check." We could do that, but before Typescript, we would also create runtime checks to confirm that data was a number in the first place.

What if we could do the same for this case? It would make sense to try to add type checking for percentages as well since that would catch the types of errors mentioned above during compile-time.

Ideally, we want to just specify that our function only accepts a percentage as its input:

function setAlpha(percentage: Percentage) { ... }
Enter fullscreen mode Exit fullscreen mode

But how do we create a Percentage type?

Defining a New Type

One may think of aliasing as creating a new type (i.e. type Percentage = number), but that merely gives a new name to an existing type. The problem with an aliased type is that it's purely descriptive rather than a functional change (i.e. it's interchangeable with the original type).

const value: number = 1.5 // Invalid `Percentage`
setAlpha(value) // Works even though setAlpha expects `Percentage`
Enter fullscreen mode Exit fullscreen mode

What we want to do is to create a new opaque type (also known as a tagged / branded type). Other type systems, like Flow, already have this ability built in, but Typescript does not.

Instead, to achieve something similar, we can 'tag' the type to indicate that it's different from the base type (in this case a number):

type Percentage = number & { _tag: 'percentage' }
Enter fullscreen mode Exit fullscreen mode

By intersecting number with a unique object, we prevent the type that we have defined from accepting any value that satisfies the base type. In this case, a generic number would not be accepted in functions that expect a Percentage:

const value: number = 0.5 // Valid `Percentage`, but typed as a general `number`
setAlpha(value) // Build Error: number instead of Percentage
Enter fullscreen mode Exit fullscreen mode

Typescript playground example

However, you may now wonder how do you define a variable as a Percentage type. The simple method is to explicitly cast the value with the as keyword:

const value = 0.5 as Percentage
Enter fullscreen mode Exit fullscreen mode

The downside is that this is only reasonable for constants specified at compile-time, what about runtime values?

Conversion Functions

We can solve this issue by creating runtime checks as functions that refine types appropriately:

function isPercentage(input: number): input is Percentage {
  return input >= 0 && input <= 1
}
Enter fullscreen mode Exit fullscreen mode

The is keyword indicates to Typescript that if this function returns true, then the input is a Percentage type (known as a type predicate). You can then use this runtime check function to create a set of conversion functions:

function toPercentage(input: number): Percentage | null {
  return isPercentage(input) ? input : null
}

function fromPercentage(input: Percentage): number {
  return input
}
Enter fullscreen mode Exit fullscreen mode

You can now use these functions throughout your codebase if you need runtime checks for data. Then you'll get the benefits of compile-time checks for functions that you defined that require these specific values.

Does this actually help?

For smaller applications, this level of type granularity is likely not particularly useful. However, as an application grows in complexity, compile-time type checking can seriously help prevent bugs related from misunderstanding the intention behind code (imagine looking at your own code from 6 months ago...).

Let's take our setAlpha example from above:

function setAlpha(percentage: number) { ... }
Enter fullscreen mode Exit fullscreen mode

It may seem obvious from the naming that this should be a value from 0 to 1, but consider potentially a value between 0% and 100%. I can understand someone thinking that setAlpha(20) is a reasonable use of the function. Only at runtime will we realize that something is wrong.

To further this problem, as your application gets even more complicated, you may not immediately notice the error during runtime. Only at a later time when you've forgotten about writing this could the issue pop up again unexpectedly.

By changing the type of the percentage parameter, we can enforce better compile-time type-checking in order to catch these issues earlier on. Now if we defined setAlpha with the more refined type:

function setAlpha(percentage: Percentage) { ... }
Enter fullscreen mode Exit fullscreen mode

Then we can get appropriate errors at compile-time when you try to pass in a generic number into the function. It will require either an explicit casting, or an assertion using our isPercentage function in order to make sure the type is correct.

This has the great benefit of allowing you to use the runtime check once for user input, and then have a compile-time guarantee for the data as it's passed through the system. Like so:

// User input is a string from a text field
const percentage = parseFloat(userInput) // type: number
if (!isPercentage(percentage)) {
  // percentage is not of type `Percentage` and is therefore
  // and invalid input. Handle appropriate error code here.
  return
}

// percentage is type `Percentage` and has compile-time checking
// to confirm that it works with the `setAlpha` function
setAlpha(percentage)
Enter fullscreen mode Exit fullscreen mode

If you were to take the same example, and remove the check for percentage's type, then you'd get a compile-time error from Typescript regarding the use of number for a parameter that requires a Percentage (Typescript playground example).

Opaque Type Variations

Now that we've hopefully determined that this is generally a good idea, let's take it up a notch with some additional variations of opaque types. All credit to ProdigySim on Github for figuring this out.

For the simplification of defining opaqueness, let's define a simple helper type:

type Tag<T> = { _tag: T }
Enter fullscreen mode Exit fullscreen mode

Weak Opaque Type

This is the opaque type we've been talking about here with Percentage. It's uses the same definition as above (though cleaned up using the helper type this time):

type Percentage = number & Tag<'percentage'>
Enter fullscreen mode Exit fullscreen mode

This weak opaque type is useful because it can be downcasted into the base type of number in cases like passing it into a function while protecting from the incorrect use of number in cases where we need a Percentage. For example:

function add(number): number
function setAlpha(Percentage): void

const num = 0.5 as number
const per = 0.5 as Percentage

add(num) // Works: number = number
add(per) // Works: Percentage downcasts to number

setAlpha(num) // Error: Cannot use a number when it expects a Percentage
setAlpha(per) // Works: Percentage = Percentage
Enter fullscreen mode Exit fullscreen mode

Strong Opaque Type

A strong opaque type is defined a little bit differently:

type Percentage = (number & Tag<'percentage'>) | Tag<'percentage'>
Enter fullscreen mode Exit fullscreen mode

Key difference is the addition of | Tag<"percentage"> at the end.

Strong opaque types have the additional restriction of requiring explicit casting in order to be used as their base types (number in this example). This means that we wouldn't be able to pass a Percentage into the add function above without explicitly casting it first:

function add(number): number
const per = 0.5 as Percentage

add(per) // Error: Percentage is incompatible with number
add(per as number) // Works: Percentage explicitly casted to number
Enter fullscreen mode Exit fullscreen mode

This can be useful in cases where you don't accidentally want to use the more specific type for more general cases without a clear exception being made via explicit casting.

Super Opaque Type

A super opaque type has the simplest definition of them all:

type Percentage = Tag<'percentage'>
Enter fullscreen mode Exit fullscreen mode

Super opaque types have the most stringent typings and cannot be implicitly or explicitly casted to their base types. This means that we need to cast the type to any first before we can cast it to the base type for use:

function add(number): number
const per = 0.5 as Percentage

add(per) // Error: Percentage is incompatible with number
add(per as number) // Error: Percentage does not sufficiently overlap number
add(per as any as number) // Works: Ignoring the inherent types to allow Percentage to be used in place of number
Enter fullscreen mode Exit fullscreen mode

This is useful for cases where you are truly making a new type that is unrelated any base type. This is like comparing number to string.

Note: Super Opaque Type boils down to just typing the data as an object with a special key, thus this can cause unexpected edge-cases when functions accept any object.

Defining Opaque Helper Types

From these different opaque types, we can define some simple helper types that reflect the different ways we can define an opaque type within Typescript:

type Tag<T> = { _tag: T }
type WeakOpaqueType<BaseType, T> = BaseType & Tag<T>
type StrongOpaqueType<BaseType, T> = (BaseType & Tag<T>) | Tag<T>
type SuperOpaqueType<T> = Tag<T>
Enter fullscreen mode Exit fullscreen mode

You can copy and use these helper types within your own code in order to begin defining your own custom types. Good luck!

3rd Party Libraries

Now you may be wondering if there are any prebuilt solutions for you to use within your code. I'm happy to say that there is! One notable library I've come across is taghiro. It defines several numeric and string types (like UUID, ascii, and regex) with corresponding validation functions to allow for assertions within your code that refines the types appropriately.

Something to note from the library is that all of the exported types are super opaque types while all of the assertions refine into weak opaque types.

Note: Iโ€™m not connected to development of taghiro. Use at your own risk.

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
tiagof
Tiago Ferreira

Posted on September 28, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About