Opaque TypeScript
Mike Skoe
Posted on March 2, 2024
Let us begin straight with the example after which we will discuss the what, why and how.
You don't need to understand everything in the code.
But note that we have two values: fahrenheit
and celsius
.
Both of them have the Fahrenheit
and Celsius
types respectively.
But even though TypeScript is a structural language and both of the types are essentially numbers, they are not interchangeable. On top of that we can't even write F.add(fahrenheit, celsius as F.Fahrenheit);
.
✨ Pure miracle ✨
The what
An opaque data type is a data type whose concrete data structure is not defined in an interface. This enforces information hiding since its values can only be manipulated by calling subroutines that have access to the missing information.
Translating it from the Wikipedian, the opaque type is an encapsulation (hiding internal structure) that is not limited to OOP (classes).
The idiomatic alternative using classes would look like this
This version is more readable and less cryptic, but we are not always able/want to use classes.
Thy why
A typical example of a classless circumstance could be a Flux architecture (like Redux), where behavior is separated from data.
But there are some other a bit less obvious reasons to give the opaque type a try:
True guard
Imagine that we need to model a domino type.
Usually, we go ahead and do something like:
type Domino = [DominoValue, DominoValue];
type DominoValue = 1 | 2 | 3 | 4 | 5 | 6;
This is a good example of using TypeScript types, but it does not always make sense or save us from a wrong structure.
Because such type is safe as long as we are responsible for making the instances.
As an example, take a look at the following express router:
Router()
.post<unknown, unknown, Domino>("/add", (req, res) => ...)
We promise to the compiler that the req.body
has the Domino
type, while it can be wrong.
Typically to guard the type we make some validation function, like function isValid(domino: Domino): boolean { ... }
, but we can forget to call the guard and all typing is just a nice description, not an actual validation.
Opaque type can help us to apply the type-level programming, where the result of a validation is another type.
So that the only way to create the domino type is a specific validation function so that the request body is just a raw input
// domino.ts
declare const domino: unique symbol;
export type Domino = [number, number] & { [domino]: "Domino" };
export function createDomino(rawData: [number, number]): Domino | false {
for (const value of rawData) {
if (value < 1 || value > 6) {
return false;
}
}
return rawData as Domino;
}
console.log(createDomino([1, 2])) // [1, 2]
console.log(createDomino([-1, 2])) // false
console.log(createDomino([5, 29])) // false
Phantom type
Phantom type is a way to differentiate types using unused generic parameters.
The most typical example is making an Id
type that is attached to a specific entity so that it can not be mixed up with another type's id.
Here is the example.
Note that even though both of the ids are strings, we can not use the post's id in place of the user's id and vice versa.
The how
As you may have noticed, the key part is the unique symbol
.
We declare a file local symbol, that serves as a specific key in an object.
Technically it is not a pure opaque type, because we still se the actual internal structure from outside.
Still, since symbols are unique, only the local symbol can be used while creating opaque types instance.
Even though we never actually initialise the symbol, we can do that, but in this case, we affect the resulting JS object's structure, which is not always desirable.
Posted on March 2, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.