beraliv

Opaque Types in TypeScript

Example of Opaque Types
1declare const __opaque__type__: unique symbol;
2
3type OpaqueType<BaseType, TagName> = BaseType & {
4 readonly [__opaque__type__]: TagName;
5};
6
7type PositiveNumber = OpaqueType<number, "PositiveNumber">;
8type NegativeNumber = OpaqueType<number, "NegativeNumber">;
9
10declare let positiveNumber: PositiveNumber;
11declare let negativeNumber: NegativeNumber;
12
13// 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:

  1. What problems do they solve
  2. What ways could we solve this problem
  3. Why I chose this solution
  4. 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:

Example of structural type system
1type Animal = { animal: true };
2type Cat = { animal: true; meow: VoidFunction };
3type Dog = { animal: true; bark: VoidFunction };
4
5declare let animal: Animal;
6declare let cat: Cat;
7declare let dog: Dog;
8
9// ✅
10animal = dog;
11animal = cat;
12// ❌ Property 'bark' is missing
13dog = cat;
14dog = animal;
15// ❌ Property 'meow' is missing
16cat = 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:

Class with private property
1class USD {
2 private __nominal: void;
3 constructor(public value: number) {}
4}
5
6class EUR {
7 private __nominal: void;
8 constructor(public value: number) {}
9}
10
11declare let usd: USD;
12declare let eur: EUR;
13
14// ❌
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:

Class with intersection types
1class Currency<T extends string> {
2 private as: T;
3}
4
5type USD = number & Currency<"USD">;
6type EUR = number & Currency<"EUR">;
7
8declare let usd: USD;
9declare let eur: EUR;
10
11// ❌
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:

Type with intersection types
1type Brand<K, T> = K & { __brand: T };
2
3type USD = Brand<number, "USD">;
4type EUR = Brand<number, "EUR">;
5
6declare let usd: USD;
7declare let eur: EUR;
8
9// ❌ someone can use it but it doesn't exist
10usd.__brand;
11
12// ❌
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:

Type with intersection types and unique symbol property
1declare const brand: unique symbol;
2
3type Brand<K, T> = K & { readonly [brand]: T };
4
5type USD = Brand<number, "USD">;
6type EUR = Brand<number, "EUR">;
7
8declare let usd: USD;
9declare let eur: EUR;
10
11// ❌
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:

ApproachError readabilityJS-freeCan be reusedEncapsulated
Class + a private property5️⃣❌ class + constructor
Class + intersection types5️⃣❌ empty class
Type + intersection types5️⃣__brand visibility in TS
Type + intersection types + unique symbol5️⃣
  1. All approaches have a great error readability (the problem is visible and it's connected to the nominal type)
  2. 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)
  3. 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

Declare unique symbol
1declare const brand: unique symbol;

Also, if you plan to reuse OpaqueType and put it to the separate file:

Opaque type implementation
1declare const __opaque__type__: unique symbol;
2
3type 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

ts-opaque-units example
1import { convert, Days } from "ts-opaque-units";
2
3const daysSinceLast6months = () => {
4 const time = 7 as Days;
5
6 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"));
9
10 return `${hours}h or ${minutes}m or ${seconds}s`;
11};
12
13document.body.innerHTML = daysSinceLast6months();

It uses ts-opaque-units which implements Opaque function with unique symbol. For instance, Days is defined as:

Days example
1import { Opaque } from "../../internal/types/Opaque";
2
3export type Days = Opaque<number, "days">;

Resources

  1. Nominal typing techniques in TypeScript

  2. Implementing an opaque type in typescript

  3. Support some non-structural (nominal) type matching #202

  4. Functional Typescript: Opaque Types

typescript
Alexey Berezin profile image

Written by Alexey Berezin who loves London 🏴󠁧󠁢󠁥󠁮󠁧󠁿, players ⏯ and TypeScript 🦺 Follow me on Twitter