Type equivalence in TypeScript is tricky
Spoiler: It is the distribution mechanism for union types within conditional types that makes equivalence non-trivial (and possibly incomputable within TypeScript). The problem is that two types with the same extension are not necessarily equivalent, i.e. they cannot be substituted for each other under all circumstances.
The usual model of a type is a (mathematical) set of values. If you create complicated types (in particular conditional ones) you probably would like to check if they work as intended. Basically, creating conditional types is like programming on the type level and accordingly you might feel the need for respective tests of your type level programs.
The possibly most basic test for type level programming is the check for type equivalence. That is, you ask whether you can substitute two types for each other on all circumstances. Based on the standard mathematical model of types as sets this should be easy. Types (considered sets) are equivalent if they have the same extension, i.e. if they comprise the exact same values.
To test two types A
and B
for equivalence we would like to create a (mapped) type type EQ<A, B> = ...
that takes those two types as (type) parameters and evaluates to true
in case they are equivalent and to false
otherwise. We could then use the TypeScript inference machinery to test our equivalence hypothesis. We declare a variable to be of type EQ<A, B>
and let TypeScript evaluate (simplify) that type. That is, we either declare let testABEquivalence: EQ<A,B>;
and use IntelliSense (hovering with the mouse over the variable name in an appropriate editor) to display the simplified type of that variable (either true
or false
) or we declare const testABEquivalence: EQ<A, B> = true;
and observe whether the TypeScript compiler complains or not.
So, based on the set model we could come up with the first (naïve) implementation of EQ
as
type EQ<A, B> =
A extends B
? (B extends A ? true : false)
: false;
that (should) check whether the two types (as sets) are subsets of each other (remember
let testExtends1: 0 | 1 extends 0 | 1 | 2 ? true : false; // true
let testExtends2: 0 | 1 | 2 extends 0 | 1 ? true : false; // false
). Unfortunately, this does not work in the general since we have not taken into account that in case type A
is a union type, TypeScript distributes the condition A extends B …
over all union members of A
. That is, if type A = A1 | A2 | ... | An
, then A extends B ? T1 : T2
evaluates to (A1 extends B ? T1 : T2) | (A2 extends B ? T1 : T2) | ... | (An extends B ? T1 : T2)
. Accordingly, we get the following (partially unintended) results
type N1 = 0 | 1 | 2;
type N2 = 1 | 2;
type M = 3 | 4;
type K = 2 | 3;let test1: EQ<N1, N1>; // boolean!
let test2: EQ<N1, N2>; // boolean!
let test3: EQ<N2, N1>; // boolean!
let test4: EQ<N1, M>; // false
let test5: EQ<M, N1>; // false
let test6: EQ<N1, K>; // boolean!
let test7: EQ<K, N1>; // boolean!
Fortunately, we can easily fix this problem by blocking the distribution mechanism (which is triggered only if left of extends
a bare type parameter is located, see Distributive Conditional Types) in wrapping A
and B
as array types:
type EQ<A, B> =
[A] extends [B]
? ([B] extends [A] ? true : false)
: false;
If we apply this modified definition to our tests above, everything works out as intended.
However, the deeper problem is that within TypeScript coextensionality is not the same as equivalence. This is a deviation from set theory, where two sets A
and B
are equivalent if and only if they have the same extension, i.e. if they are subsets of each other. With other words, for TypeScript set theory as a type model is an oversimplification. As said in the introduction, the distribution mechanism within conditional types again is the reason. Due to this mechanism it might matter how a type is syntactically defined, i.e. whether a union is expanded or not. Two coextensional types with expanded or unexpanded union subtypes may behave different in different (type) contexts.
The possible non-equivalence of coextensional types came as a big surprise to me (even though the reason is clear once you think about it) when I tried to replace one type with another, simpler coextensional one and the TypeScript compiler complained. This situation occured when I was migrating from plain old Java objects to their immutable versions using the library Immutable.js. It might save you a lot of debugging time if you are aware of this complication.
The following code example demonstrates the problem: Types W
and V
are coextensional and yet their derived types Mapped<W>
and Mapped<V>
are not (in TypeScript Playground):
type Impossible = never;type EQ<A, B> =
[A] extends [B]
? ([B] extends [A]
? true
: false)
: false;type W = { p: 0 | 1 | 2};
type V = { p: 0 } | { p: 1 } | { p: 2 };// W and V are coextensional
let testCoextensionality: EQ<W, V>; // truetype Mapped<S extends W> =
S extends unknown // enforce distribution
? (S["p"] extends 1
? never
: S)
: Impossible;let testMapped1: EQ<Mapped<W>, Mapped<V>>; //false!let testMapped2: EQ<Mapped<V>, { p: 0 } | { p: 2 }>; // true
let testMapped3: EQ<Mapped<W>, { p: 0 } | { p: 2 }>; // false
let testMapped4: EQ<Mapped<W>, W>; // true
let testMapped5: EQ<Mapped<V>, W>; // false
Unfortunately, I have no simple idea how to mechanically check for full type equivalence in TypeScript, i.e. how to write an improved version of type EQ
. My considerations so far are the following: Two complex types are equivalent in TypeScript if they are coextensional and all their corresponding subtypes are equivalent (a recursive definition). Two types are primitive (i.e. non-complex) in the relevant sense here if they have no corresponding subtypes. Two primitive subtypes are equivalent if they are coextensional and both resolve/simplify to (finite) union types with the same cardinality. Resolve/simplify here means to resolve possible type aliases. Just an example what I have in mind with “corresponding” subtypes:
type T = { q: 'a' | 'b' };
type S = { q: 'a' } | { q: 'b' };
type W = { p: T };
type V = { p: S };
If we want to compare W
and V
, T
and S
play here the role of corresponding subtypes. T
and S
are coextensional and so are W
and V
and yet the latter two are not equivalent since their corresponding subtypes T
and S
are not. T
and S
are primitive (in the sense relevant here) and they are not equivalent since S
is a finite union of cardinality 2 while T
is one of cardinality 1. However, it is unclear to me how to spell out the general definitions of “corresponding subtypes” and “primitive”, if this is possible at all and if it is worth the effort. Ideas are welcome.