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) { ... }
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
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) { ... }
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`
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' }
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
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
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
}
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
}
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) { ... }
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) { ... }
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)
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 }
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'>
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
Strong Opaque Type
A strong opaque type is defined a little bit differently:
type Percentage = (number & Tag<'percentage'>) | Tag<'percentage'>
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
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'>
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
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>
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.
Top comments (0)