In this blog post, we explore how we can test that complicated TypeScript types work as expected. To do that, we need assertions at the type level and other tools.
Asserting at the type level
Writing more complicated types is like programming at a different level:
- At the program level, we use JavaScript – e.g.:
- Values
- Functions with parameters
- At the type level, we use (non-JavaScript) TypeScript – e.g.:
- Types
- Generic types with type parameters
At the program level, we can use assertions such as assert.deepEqual()
to test our code:
const pair = (x) => [x, x];
const result = pair('abc');
assert.deepEqual(
result, ['abc', 'abc']
);
So how can we test type-level code – which is important for complicated types? We also need assertions – e.g.:
type Pair = [T, T];
type Result = Pair<'abc'>;
type _ = Assert<Equal<
Result, ['abc', 'abc']
>>;
The generic types Assert
and Equal
are part of my npm package asserttt
. In this blog post, we’ll use this package in two ways:
- On one hand, we reimplement its API to see how it works.
- On the other hand, we use its API to check that what we have implemented works as desired.
To avoid confusion, I’ll use different names in our version of asserttt
.
How to check if two types are equal?
The most important part of a type-level assertion API is checking whether two types are equal. As it turns out, that is surprisingly difficult.
A naive solution
A naive solution works as follows. Two types X
and Y
are equal if both:
X
extendsY
(X
is a subtype ofY
).Y
extendsX
.
We’d think that that is only the case if X
and Y
are equal (which is almost true, as we’ll see soon).
Let’s implement this approach via a generic type SimpleEqual1
:
type SimpleEqual1 =
X extends Y
? (Y extends X ? true : false)
: false
;
type _ = [
Assert<Equal<
SimpleEqual1<'hello', 'hello'>,
true
>>,
Assert<Equal<
SimpleEqual1<'yes', 'no'>,
false
>>,
Assert<Equal<
SimpleEqual1<string, 'yes'>,
false
>>,
Assert<Equal<
SimpleEqual1<'a', 'a'|'b'>,
true | false
>>,
];
The test cases start off well: line A and line B produce the expected results and even the more tricky check in line C works correctly.
Alas, in line D, the result is both true
and false
. Why is that? SimpleEqual1
is defined using conditional types and those are distributive over union types (more information).
Disabling distribution
In order for SimpleEqual
to work as desired, we need to switch off distribution. A conditional type is only distributive if the left-hand side of extends
is a bare type variable (more information). Therefore, we can disable distribution by turning both sides of extends
into single-element tuples:
type SimpleEqual2 =
[X] extends [Y]
? ([Y] extends [X] ? true : false)
: false
;
type _ = [
Assert<Equal<
SimpleEqual2<'hello', 'hello'>,
true
>>,
Assert<Equal<
SimpleEqual2<'yes', 'no'>,
false
>>,
Assert<Equal<
SimpleEqual2<string, 'yes'>,
false
>>,
Assert<Equal<
SimpleEqual2<'a', 'a'|'b'>,
false
>>,
Assert<Equal<
SimpleEqual2<any, 123>,
true
>>,
];
Now we can also handle union types correctly (line A). However, one problem remains (line B): any
is equal to any other type. That means we can’t really check if a given type is any
. And we can’t check that a type is not any
because if it is, it’ll be equal to anything we compare it too.
Strictly comparing any
There is no straightforward way of implementing an equality check in a way that distinguishes any
from other types. Therefore, we have to resort to a hack:
type StrictEqual =
(() => T extends X ? 1 : 2) extends
( () => T extends Y ? 1 : 2) ? true : false
;
type _ = [
Assert<Equal<
StrictEqual<'hello', 'hello'>,
true
>>,
Assert<Equal<
StrictEqual<'yes', 'no'>,
false
>>,
Assert<Equal<
StrictEqual<string, 'yes'>,
false
>>,
Assert<Equal<
StrictEqual<'a', 'a'|'b'>,
false
>>,
Assert<Equal<
StrictEqual<any, 123>,
false
>>,
Assert<Equal<
StrictEqual<any, any>,
true
>>,
];
This hack was suggested by Matt McCutchen (source). And does indeed what we want (line C and line D). But how does it work (source)?
In order to check whether the function type in line A extends the function type in line B, TypeScript has to compare the following two conditional types:
T extends X ? 1 : 2
T extends Y ? 1 : 2
Since T
does not have a value, both conditional types are deferred. Assignability of two deferred conditional types is computed via the internal function isTypeIdenticalTo()
and only true
if:
- Both have the same constraint.
- Their true branches have the same type and their false branches have the same type.
Thanks to #1, X
and Y
are compared precisely.
How do we assert that something must be true
?
At the program/JavaScript level, we can throw an exception if an assertion fails:
function assert(condition) {
if (condition === false) {
throw new Error('Assertion failed');
}
}
function equal(x, y) {
return x === y;
}
assert(equal(3, 4))