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.
Writing more complicated types is like programming at a different level:
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, 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:
To avoid confusion, I’ll use different names in our version of asserttt
.
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 works as follows. Two types X
and Y
are equal if both:
X
extends Y
(X
is a subtype of Y
).Y
extends X
.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, Y> =
X extends Y
? (Y extends X ? true : false)
: false
;
type _ = [
Assert<Equal<
SimpleEqual1<'hello', 'hello'>, // (A)
true
>>,
Assert<Equal<
SimpleEqual1<'yes', 'no'>, // (B)
false
>>,
Assert<Equal<
SimpleEqual1<string, 'yes'>, // (C)
false
>>,
Assert<Equal<
SimpleEqual1<'a', 'a'|'b'>, // (D)
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).
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, Y> =
[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'>, // (A)
false
>>,
Assert<Equal<
SimpleEqual2<any, 123>, // (B)
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.
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<X, Y> =
(<T>() => T extends X ? 1 : 2) extends // (A)
(<T>() => T extends Y ? 1 : 2) ? true : false // (B)
;
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>, // (C)
false
>>,
Assert<Equal<
StrictEqual<any, any>, // (D)
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:
Thanks to #1, X
and Y
are compared precisely.
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)); // throws an exception
Alas, there is no way to fail at compile time in TypeScript. If there were, it could look like this:
type AssertType1<B extends boolean> = B extends true ? void : Fail;
This type has the same result as a void
function if B
is true
. If it is false
then it fails at the type level, via the invented pseudo-type Fail
.
The closest thing to Fail
that TypeScript currently has, is never
. However, never
does not cause type checking errors.
Thankfully, there is a decent workaround that mostly gives us what we want:
type AssertType2<_B extends true> = void; // (A)
type _ = [
AssertType2<true>, // OK
// @ts-expect-error: Type 'false' does not satisfy
// the constraint 'true'.
AssertType2<false>,
];
When we pass arguments to a generic type, the extends
constraints of all of its parameters must be fulfilled. Otherwise, we get a type-level failure. That’s what we use in line A: The value we pass to AssertType2
must be true
or the type checker complains.
This workaround has limits, though. The following functionality can only be implemented via Fail
:
type AssertEqual<X, Y> = true extends Equal<X, Y> ? void : Fail;
Not
: utility type for boolean negation Switching back to asserttt
names – once we have Equal
and Assert
, we can implement more helper types – e.g. Not<B>
:
type Not<B extends boolean> = B extends true ? false : true;
Not
enables us to assert that one type is not equal to another type:
type _ = Assert<Not<Equal<
'yes', 'no'
>>>;
A generic type whose result is true
or false
is called a predicate. Such types can be used with Assert
. Equal
and Not
are predicates. But more predicates are conceivable and useful – e.g.:
/**
* Is type `Target` assignable from type `Source`?
*/
type Assignable<Target, Source> = Source extends Target ? true : false;
type _ = [
Assert<Assignable<number, 123>>,
Assert<Assignable<123, 123>>,
Assert<Not<Assignable<123, number>>>,
Assert<Not<Assignable<number, 'abc'>>>,
];
asserttt
defines more predicates.
Sometimes, we need to test that an error happens where we expect it. At the JavaScript level, we can use functions such as assert.throws()
:
assert.throws(
() => null.prop,
{
name: 'TypeError',
message: "Cannot read properties of null (reading 'prop')",
}
);
At the type level, we can use @ts-expect-error
:
// @ts-expect-error: The value 'null' cannot be used here.
null.prop;
By default, @ts-expect-error
only checks that an error exists not which error it is. To check the latter, we can use a tool such as the npm package ts-expect-error.
For fun, let’s compare another JavaScript-level test with its analog at the type level. This is the JavaScript code:
function upperCase(str) {
if (typeof str !== str) {
throw new TypeError('Not a string: ' + str);
}
return str.toUpperCase();
}
assert.throws(
() => upperCase(123),
{
name: 'TypeError',
message: 'Not a string: 123',
}
);
For the type-level code, I’m omitting the runtime type check – even though that can often still make sense.
function upperCase(str: string) {
return str.toUpperCase();
}
// @ts-expect-error: Argument of type 'number' is not assignable to
// parameter of type 'string'.
upperCase(123);
When testing types, we also may want to check if a value has a given type – as provided via inference or a generic type. One way of doing so is via the typeof
operator (line A):
const pair = <T>(x: T): [T, T] => [x, x];
const value = pair('a' as const);
type _ = Assert<Equal<
typeof value, ['a', 'a'] // (A)
>>;
Another option is via a helper function assertType()
:
assertType<['a', 'a']>(value);
Using a program-level function makes this check less verbose because we can directly accept program-level values, we don’t have to convert them to type-level values via typeof
. This is what assertType()
looks like:
function assertType<T>(_value: T): void { }
We don’t do anything with the parameter _value
; we only statically check if it is assignable to the type parameter T
. One limitation of assertType()
is that it only checks assignability; it does not check type equality. For example, we can’t check that a value has the type string
and not a more specific type:
const value_string: string = 'abc';
const value_abc: 'abc' = 'abc';
assertType<string>(value_string);
assertType<string>(value_abc);
In line A, the type of value_abc
is assignable to string
but it is not equal to string
.
In contrast, Equal
enables us to check that a value does not have a type that is more specific than string
:
type _ = [
Assert<Equal<
typeof value_string, string
>>,
Assert<Not<Equal<
typeof value_abc, string
>>>,
];
Occasionally, type-level assertions are even useful in normal (non-test) code. For example, let’s assume we both have a type Person
and an Array personKeys
with the keys of that type:
interface Person {
first: string;
last: string;
}
const personKeys = [
'first',
'last',
] as const;
If we want the compiler to warn us if we get personKeys
wrong, we can use a type-level assertion:
type _1 = Assert<Equal<
keyof Person,
(typeof personKeys)[number] // (A)
>>;
In line A, we use the indexed access operator T[K]
to convert the Array to a union of the types of its elements (more information on this technique):
type _2 = [
Assert<Equal<
(typeof personKeys)[number],
'first' | 'last'
>>,
Assert<Equal<
keyof Person,
'first' | 'last'
>>,
];
To run normal tests written in TypeScript, we run the transpiled JavaScript. If tests include type-level assertions, we need to additionally type check them. Two options are:
tsc
.