In this chapter, 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 chapter, we’ll use this package in two ways:
To avoid confusion, the names of our types always start with T
:
asserttt
type: MutuallyAssignable
TMutuallyAssignable
any
?In this section, we explore how to check if a given type is any
. That will help us later with defining TEqual<X, Y>
.
How can we detect any
by only checking assignability via extends
? Special types we have to consider:
any
is assignable to and from any type – with one exception: It’s not assignable to never
.
unknown
. unknown
is not assignable to any type (other than unknown
and any
).
never
is assignable to any type. No type (other than never
) is assignable to never
; not even any
can be assigned to never
.
Therefore, a type T
is any
if both:
T
is assignable to the number literal type 1
.
2
is assignable to T
.
After check 1, T
can be 1
, never
or any
. Check 2 excludes 1
and never
. This is an implementation of the two checks:
type TIsAny<T> = [T, 2] extends [1, T] ? true : false;
type _ = [
Assert<Equal<
TIsAny<any>, true
>>,
Assert<Equal<
TIsAny<unknown>, false
>>,
Assert<Equal<
TIsAny<never>, false
>>,
Assert<Equal<
TIsAny<1>, false
>>,
Assert<Equal<
TIsAny<2>, false
>>,
];
An even more concise solution was proposed by Joe Calzaretta:
type TIsAny<T> = 0 extends (1 & T) ? true : false;
How does this code work?
The condition 0 extends 1
fails. It checks if 0
is a subset of 1
(if 0
is assignable to 1
).
For all normal types T
, the intersection 1 & T
is as big as the type 1
or smaller. Therefore, the result of the check stays the same.
Only the type any
is different, because 1 & any
is any
and 0
is a subset of any
.
type _X = Assert<Equal<
1 & any,
any
>>;
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.
TMutuallyAssignable
What does two X
and Y
being equal actually mean? One reasonable definition is:
X
is assignable to Y
and
Y
is assignable to X
.
We check assignability via extends
:
type TMutuallyAssignable<X, Y> =
X extends Y
? (Y extends X ? true : false)
: false
;
type _ = [
Assert<Equal<
TMutuallyAssignable<'hello', 'hello'>, // (A)
true
>>,
Assert<Equal<
TMutuallyAssignable<'yes', 'no'>, // (B)
false
>>,
Assert<Equal<
TMutuallyAssignable<string, 'yes'>, // (C)
false
>>,
Assert<Equal<
TMutuallyAssignable<'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? TMutuallyAssignable
is defined using conditional types and those are distributive over union types (more information).
In order for TMutuallyAssignable
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 TMutuallyAssignable<X, Y> =
[X] extends [Y]
? ([Y] extends [X] ? true : false)
: false
;
We can simplify this TMutuallyAssignable
a little by doing both extends
checks at the same time (the square brackets still ensure that no distribution happens):
export type TMutuallyAssignable<X, Y> =
[X, Y] extends [Y, X] ? true : false
;
Let’s test the non-distributive version of TMutuallyAssignable
:
type _ = [
Assert<Equal<
TMutuallyAssignable<'hello', 'hello'>,
true
>>,
Assert<Equal<
TMutuallyAssignable<'yes', 'no'>,
false
>>,
Assert<Equal<
TMutuallyAssignable<string, 'yes'>,
false
>>,
Assert<Equal<
TMutuallyAssignable<'a', 'a'|'b'>, // (A)
false
>>,
Assert<Equal<
TMutuallyAssignable<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:
any
then all actual types are equal to it.
any
will always be equal, too.
any
is only equal to itselfWe can use TMutuallyAssignable
to define a generic type TEqual
that fixes the issues with any
(we use the utility TIsAny
from earlier):
type TEqual<X, Y> =
[TIsAny<X>, TIsAny<Y>] extends [true, true] ? true
: [TIsAny<X>, TIsAny<Y>] extends [false, false] ? MutuallyAssignable<X, Y>
: false
;
Our approach is:
X
and Y
are any
, they are equal.
X
nor Y
is any
, they are equal if they are mutually assignable.
X
or only Y
is any
and they are not equal.
TEqual
passes all of the following tests:
type _ = [
Assert<Equal<
TEqual<'hello', 'hello'>,
true
>>,
Assert<Equal<
TEqual<'yes', 'no'>,
false
>>,
Assert<Equal<
TEqual<string, 'yes'>,
false
>>,
Assert<Equal<
TEqual<'a', 'a'|'b'>,
false
>>,
Assert<Equal<
TEqual<any, 123>, // (A)
false
>>,
Assert<Equal<
TEqual<any, any>, // (B)
true
>>,
];
TEqual
passes the test in line A, which TMutuallyAssignable
failed. Line B contains an addition check for any
– to make sure everything works correctly.
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 negationOnce we have Equal
and Assert
, we can implement more helper types – e.g. Not<B>
:
type TNot<B extends boolean> = [B] extends [true] ? false : true;
The square brackets around B
prevent distribution. TNot
enables us to assert that one type is not equal to another type:
type _ = Assert<TNot<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 TAssignable<Target, Source> = [Source] extends [Target] ? true : false;
type _ = [
Assert<TAssignable<number, 123>>,
Assert<TAssignable<123, 123>>,
Assert<Not<TAssignable<123, number>>>,
Assert<Not<TAssignable<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 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, consider the following enum pattern:
const OutputFormat = {
html: 'HTML',
epub: 'EPUB',
pdf: 'PDF',
} as const;
type OutputFormatType = (typeof OutputFormat)[keyof typeof OutputFormat];
type OutputFormatKey = keyof (typeof OutputFormat);
Let’s assume we want to use Zod to validate a JSON property whose value is one of the keys of OutputFormat
. Then we need an Array that we can pass to z.enum()
:
const OUTPUT_FORMAT_KEYS = ['html', 'epub', 'pdf'] as const;
Why don’t we create OUTPUT_FORMAT_KEYS
via Object.keys()
? Zod needs a tuple so that it can infer a static type.
To ensure that OUTPUT_FORMAT_KEYS
is consistent with OutputFormatKey
, we can use the following type-level assertion:
type _ = Assert<Equal<
(typeof OUTPUT_FORMAT_KEYS)[number], // (A)
OutputFormatKey
>>;
In line A, we use an indexed access type T[K]
to convert the Array OUTPUT_FORMAT_KEYS
to a union of the types of its elements (more information).
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
.