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.
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:
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:
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:
But how do we create a Percentage
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).
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
):
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
:
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:
The downside is that this is only reasonable for constants specified at compile-time, what about runtime values?
We can solve this issue by creating runtime checks as functions that refine types appropriately:
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:
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.
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:
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:
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:
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).
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:
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):
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:
A strong opaque type is defined a little bit differently:
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:
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.
A super opaque type has the simplest definition of them all:
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:
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.
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:
You can copy and use these helper types within your own code in order to begin defining your own custom types. Good luck!
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.
I’m not connected to development of taghiro
. Use at your own risk.