Have you ever called a multi-argument function with arguments in the wrong order? TypeScript does a great job of preventing that from happening - as long as arguments have incompatible types. But can we make TypeScript warn us about this kind of mistakes if, for instance, all arguments are numbers? In other words: if numbers were fruits, can TypeScript know the difference between apples and oranges?
The problem
Let's take a look at the following code:
1
2
3
4
5
6
7
8
9
10
11
function formatSentence(vehicle: string, speed: number) {
return `The ${vehicle} was moving with a speed of ${speed} km/h.`;
}
function calculateSpeed(distance: number, time: number) {
return distance / time;
}
const distance = 240;
const time = 3;
const speed = calculateSpeed(time, distance);
const sentence = formatSentence(speed, "car"); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.
console.log(sentence);
TypeScript easily finds the error in line 10 - arguments were given in the wrong order. We fix the problem, run the program and what we get is this: "The car was moving with a speed of 0.0125 km/h.". That's a very slow car... or maybe there is another bug in our code? It turns out that the order of arguments in line 9 is wrong. We fix the code again; now we get: "The car was moving with a speed of 80 km/h.". This looks like a correct result. But why did TypeScript find the first error and didn't see the second one? The clue is in the error message: "Argument of type 'number' is not assignable to parameter of type 'string'". In the second case, we had two arguments with type "number" - number is assignable to number. Is there anything we can do to avoid this kind of bugs? Of course we can!
The solution
The thing we are looking for is called "opaque types". They utilize TypeScript's intersection types, for example:
1
type Distance = number & { __distance: never };
In the above code snippet we created a numeric field, but we are not limited to numbers - opaque types can be created from other types too. One thing to keep in mind is that property names (here: "__distance") should be unique. Let's see what is and what isn't assignable:
1
2
3
4
5
6
7
8
9
10
11
type Distance = number & { __distance: never };
type Time = number & { __time: never };
let plainNumber = 5;
let distance = 10 as Distance;
let time = 15 as Time;
plainNumber = distance; // Ok
distance = plainNumber; // Error: Type 'number' is not assignable to type 'Distance'. Type 'number' is not assignable to type '{ __distance: never; }'.
distance = plainNumber as Distance; // Ok
distance = time; // Error: Type 'Time' is not assignable to type 'Distance'. Property '__distance' is missing in type 'Number & { __time: never; }' but required in type '{ __distance: never; }'.
distance = time as Distance; // Error: Conversion of type 'Time' to type 'Distance' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Property '__distance' is missing in type 'Number & { __time: never; }' but required in type '{ __distance: never; }'.
distance = time as number as Distance; // Ok
It looks like we achieved our goal - we can't assign Time to Distance unless we really want to do it (see line 11). However, one thing to consider is that we won't be able to assign plain numbers to vars with opaque types. This also applies to function calls:
1
2
3
4
5
type Distance = number & { __distance: never };
type Time = number & { __time: never };
function f(d: Distance, t: Time) { }
f(3, 5); // Error: Argument of type 'number' is not assignable to parameter of type 'Distance'. Type 'number' is not assignable to type '{ __distance: never; }'.
f(3 as Distance, 5 as Time); // Ok
It may be inconvenient to use "as ...", but it improves safety and code readability - meaning of "3 as Distance" is more obvious than just "3". Let's add opaque types to our first code and see what happens.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Distance = number & { __distance: never };
type Speed = number & { __speed: never };
type Time = number & { __time: never };
function formatSentence(vehicle: string, speed: Speed) {
return `The ${vehicle} was moving with a speed of ${speed} km/h.`;
}
function calculateSpeed(distance: Distance, time: Time) {
return distance / time as Speed;
}
const distance = 240 as Distance;
const time = 3 as Time;
const speed = calculateSpeed(time, distance); // Error: Argument of type 'Time' is not assignable to parameter of type 'Distance'. Property '__distance' is missing in type 'Number & { __time: never; }' but required in type '{ __distance: never; }'.
const sentence = formatSentence("car", speed);
console.log(sentence);
Now it works! And by "works" I mean that there is an error in line 12, which is exactly what we wanted. However, there is one worrying thing... what will happen if we have two opaque types with the same property name? This might happen in large codebases or because of copy-pasting.
1
2
3
4
5
type Distance = number & { __distance: never };
type Time = number & { __distance: never }; // "__distance" again
let distance = 10 as Distance;
let time = 15 as Time;
distance = time; // Ok
Well... it looks like this solution is not perfect. Can we improve it?
The second solution
Let's modify properties used in our opaque types:
1
2
3
4
5
6
7
8
9
10
11
type Distance = number & { [__Distance]: never }; declare const __Distance: unique symbol;
type Time = number & { [__Time]: never }; declare const __Time: unique symbol;
let plainNumber = 5;
let distance = 10 as Distance;
let time = 15 as Time;
plainNumber = distance; // Ok
distance = plainNumber; // Error: Type 'number' is not assignable to type 'Distance'. Type 'number' is not assignable to type '{ [__Distance]: never; }'.
distance = plainNumber as Distance; // Ok
distance = time; // Error: Type 'Time' is not assignable to type 'Distance'. Property '[__Distance]' is missing in type 'Number & { [__Time]: never; }' but required in type '{ [__Distance]: never; }'.
distance = time as Distance; // Error: Conversion of type 'Time' to type 'Distance' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Property '[__Distance]' is missing in type 'Number & { [__Time]: never; }' but required in type '{ [__Distance]: never; }'.
distance = time as number as Distance; // Ok
The code above works just like our first solution, but it solves the "duplicate property name" problem. We can't redeclare const in the same file and consts declared with the same name in different files are considered different. We can also use generic types to improve code readability:
1
2
type Opaque<TPrimary, TUnique extends symbol> = TPrimary & { [P in TUnique]: never };
type Distance = Opaque<number, typeof __Distance>; declare const __Distance: unique symbol;
Ok, great, but we also want to have opaque types PersonName, FemaleName and MaleName that follow these rules:
- PersonName can't be assigned to FemaleName/MaleName without "as",
- FemaleName/MaleName can be assigned to PersonName without "as",
- FemaleName can't be assigned to MaleName without "as" and vice versa.
Is it possible? The answer is... yes. We can base FemaleName and MaleName types on PersonName. Here is an example:
1
2
3
4
5
6
7
8
9
10
11
12
13
type Opaque<TPrimary, TUnique extends symbol> = TPrimary & { [P in TUnique]: never };
type PersonName = Opaque<string, typeof __PersonName>; declare const __PersonName: unique symbol;
type FemaleName = Opaque<PersonName, typeof __FemaleName>; declare const __FemaleName: unique symbol;
type MaleName = Opaque<PersonName, typeof __MaleName>; declare const __MaleName: unique symbol;
let personName = "Quinn" as PersonName;
let femaleName = "Alice" as FemaleName;
let maleName = "Bob" as MaleName;
personName = femaleName; // Ok
femaleName = personName; // Error: Type 'PersonName' is not assignable to type 'FemaleName'. Property '[__FemaleName]' is missing in type 'String & { [__PersonName]: never; }' but required in type '{ [__FemaleName]: never; }'.
femaleName = personName as FemaleName; // Ok
femaleName = maleName; // Error: Type 'MaleName' is not assignable to type 'FemaleName'. Property '[__FemaleName]' is missing in type 'String & { [__PersonName]: never; } & { [__MaleName]: never; }' but required in type '{ [__FemaleName]: never; }'.
femaleName = maleName as FemaleName; // Error: Conversion of type 'MaleName' to type 'FemaleName' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Property '[__FemaleName]' is missing in type 'String & { [__PersonName]: never; } & { [__MaleName]: never; }' but required in type '{ [__FemaleName]: never; }'.
femaleName = maleName as PersonName as FemaleName; // Ok
Summary
The greatest advantage of opaque types is that they make it easy to find many errors early. They save us a lot of time that we would otherwise spend debugging our software. The main disadvantage is that we have to write a bit more code - we have to define these types and sometimes use "as" type assertions. However, these assertions can be considered advantageous - they make code more readable. For example "speed(3 as Time, 7 as Distance)" is easier to understand than "speed(3, 7)". Of course we don't have to choose between using opaque types for every single variable and not using them at all. We should only use them where they actually improve our code. In my opinion, the pros outweigh the cons. That's why I use opaque types in all my major projects.