Opaque Types in TypeScript
1declare const __opaque__type__: unique symbol;23type OpaqueType<BaseType, TagName> = BaseType & {4 readonly [__opaque__type__]: TagName;5};67type PositiveNumber = OpaqueType<number, "PositiveNumber">;8type NegativeNumber = OpaqueType<number, "NegativeNumber">;910declare let positiveNumber: PositiveNumber;11declare let negativeNumber: NegativeNumber;1213// Type '"NegativeNumber"' is not assignable to type '"PositiveNumber"'14positiveNumber = negativeNumber;15// Type '"PositiveNumber"' is not assignable to type '"NegativeNumber"'16negativeNumber = positiveNumber;
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:
1type Animal = { animal: true };2type Cat = { animal: true; meow: VoidFunction };3type Dog = { animal: true; bark: VoidFunction };45declare let animal: Animal;6declare let cat: Cat;7declare let dog: Dog;89// ✅10animal = dog;11animal = cat;12// ❌ Property 'bark' is missing13dog = cat;14dog = animal;15// ❌ Property 'meow' is missing16cat = dog;17cat = animal;
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:
1class USD {2 private __nominal: void;3 constructor(public value: number) {}4}56class EUR {7 private __nominal: void;8 constructor(public value: number) {}9}1011declare let usd: USD;12declare let eur: EUR;1314// ❌15// Type 'EUR' is not assignable to type 'USD'.16// Types have separate declarations of a private property '__nominal'.17usd = eur;18eur = usd;
Playground – https://tsplay.dev/we4a1W
2. Class + intersection types
We still define class
here, but for every nominal type we have Generic type:
1class Currency<T extends string> {2 private as: T;3}45type USD = number & Currency<"USD">;6type EUR = number & Currency<"EUR">;78declare let usd: USD;9declare let eur: EUR;1011// ❌12// Type 'EUR' is not assignable to type 'USD'.13// Type 'EUR' is not assignable to type 'Currency<"USD">'.14// Types of property 'as' are incompatible.15// Type '"EUR"' is not assignable to type '"USD"'16usd = eur;17eur = usd;
Playground – https://tsplay.dev/wgXa9N
3. Type + intersection types
We only define type
here and use Generic type with intersection types:
1type Brand<K, T> = K & { __brand: T };23type USD = Brand<number, "USD">;4type EUR = Brand<number, "EUR">;56declare let usd: USD;7declare let eur: EUR;89// ❌ someone can use it but it doesn't exist10usd.__brand;1112// ❌13// Type 'EUR' is not assignable to type 'USD'.14// Type 'EUR' is not assignable to type '{ __brand: "USD"; }'.15// Types of property '__brand' are incompatible.16// Type '"EUR"' is not assignable to type '"USD"'17usd = eur;18eur = usd;
Playground – https://tsplay.dev/NnQM6w
4. Type + intersection types + unique symbol
We still define type
, use Generic type, use intersection types with unique symbol
:
1declare const brand: unique symbol;23type Brand<K, T> = K & { readonly [brand]: T };45type USD = Brand<number, "USD">;6type EUR = Brand<number, "EUR">;78declare let usd: USD;9declare let eur: EUR;1011// ❌12// Type 'EUR' is not assignable to type 'USD'.13// Type 'EUR' is not assignable to type '{ readonly [brand]: "USD"; }'.14// Types of property '[brand]' are incompatible.15// Type '"EUR"' is not assignable to type '"USD"'16usd = eur;17eur = usd;
Playground – https://tsplay.dev/WkMljN
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
1declare const brand: unique symbol;
Also, if you plan to reuse OpaqueType
and put it to the separate file:
1declare const __opaque__type__: unique symbol;23type OpaqueType<K, T> = K & { readonly [__opaque__type__]: T };
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
1import { convert, Days } from "ts-opaque-units";23const daysSinceLast6months = () => {4 const time = 7 as Days;56 const hours = Math.floor(convert(time, "days", "hours"));7 const minutes = Math.floor(convert(time, "days", "minutes"));8 const seconds = Math.floor(convert(time, "days", "seconds"));910 return `${hours}h or ${minutes}m or ${seconds}s`;11};1213document.body.innerHTML = daysSinceLast6months();
It uses ts-opaque-units which implements Opaque
function with unique symbol. For instance, Days
is defined as:
1import { Opaque } from "../../internal/types/Opaque";23export type Days = Opaque<number, "days">;