Opaque Types in TypeScript
Today we discuss Opaque types:
- What problems do they solve
- What ways could we solve this problem
- Why I chose this solution
- Describe the solution in more technical details
The problem
TypeScript, like Elm and Haskell, has a structural type system. It means that 2 different types but of the same shape are compatible:
It leads to more flexibility but at the same time leaves a room for specific bugs.
Nominal typing system, on the other hand, would throw an error in this case because types don't inherit each other so no instance of one type cannot be assigned to the instance of another type.
TypeScript didn't resolve nominal type feature and since 23 Jul 2014 has an open issue: Support some non-structural (nominal) type matching #202.
Ryan Cavanaugh described the cases in the comment where nominal types would be useful.
Probable solutions
Let's see how we can imitate nominal type feature for TypeScript 4.2:
1. Class + a private property
Here we define class
for every nominal type and add __nominal
mark as a private property:
2. Class + intersection types
We still define class
here, but for every nominal type we have Generic type:
3. Type + intersection types
We only define type
here and use Generic type with intersection types:
4. Type + intersection types + unique symbol
We still define type
, use Generic type, use intersection types with unique symbol
:
Choose the solution
Let's compare all the approaches that are mentioned above:
Approach | Error readability | JS-free | Can be reused | Encapsulated |
---|---|---|---|---|
Class + a private property | 5️⃣ | ❌ class + constructor | ❌ | ✅ |
Class + intersection types | 5️⃣ | ❌ empty class | ✅ | ✅ |
Type + intersection types | 5️⃣ | ✅ | ✅ | ❌ __brand visibility in TS |
Type + intersection types + unique symbol | 5️⃣ | ✅ | ✅ | ✅ |
- All approaches have a great error readability (the problem is visible and it's connected to the nominal type)
- First 2 approaches use JS: Class + a private property cannot be reused, Class + intersection types can be reused but still creates empty class (which is fine)
- By encapsulation here Type + intersection types make
__brand
property visible outside and can lead to stupid errors which I want to get rid of.
So if you don't really want to see one empty class, please use Type + intersection types + unique symbol
If one empty class is still okay, you can choose Class + intersection types
I will stop on Type + intersection types + unique symbol
unique symbol
It's possible to create a symbol in TypeScript without creating it in JavaScript. So it won't exist after compiling
Also, if you plan to reuse OpaqueType
and put it to the separate file:
It's a good idea as in this case symbol
won't be accessible outside of the file and therefore you cannot read the property.
Example
Let's have a look at CodeSandbox
It uses ts-opaque-units which implements Opaque
function with unique symbol. For instance, Days
is defined as: