In this blog post, we examine how we can test types in TypeScript:
asserttt
and the CLI tool ts-expect-error
.Since we can program at the type level with generic types (type-level functions), conditional types, mapped types, etc., we also need a way to write tests at the type level.
Testing types helps with documenting features – with code that can be automatically tested via tools such as Markcheck. This is one of many examples in “Exploring TypeScript”. Even though that looks uglier than other ways of showing types in code examples, it has several benefits.
TypeScript exercises such as the ones provided by Type<Challenge[]>
require a way to test types.
In normal code, it can help ensure that related types are consistent.
import { type Assert, type Equal } from 'asserttt';
type UnionOf<Tup extends ReadonlyArray<unknown>> = Tup[number];
// Test: Does `UnionOf` turn a tuple into a union?
type _ = Assert<Equal<
UnionOf<['rose', 'sunflower', 'lavender']>,
'rose' | 'sunflower' | 'lavender'
>>;
Explanations:
asserttt
.Assert<B>
fails if its parameter B
is not true
.Equal<X, Y>
returns true
if its parameters X
and Y
are equal and false
, otherwise.The following code demonstrates the effects of the compiler option noImplicitAny
:
// @ts-expect-error: Parameter 'name' implicitly has an 'any' type.
function hello(name) {
return `Hello ${name}!`
}
By default, TypeScript only warns us if there is no error in the line after @ts-expect-error
. The tool ts-expect-error additionally warns us if the error message does not match what comes after the colon.
asserttt
provides the following utility types:
Assert<B>
(compiler error if B
isn’t true
)assertType<T>(value)
Equal<X, Y>
MutuallyAssignable<X, Y>
PedanticEqual<X, Y>
Extends<Sub, Super>
Assignable<Target, Source>
Includes<Superset, Subset>
IsAny<T>
Not<B>
Those utility types work reasonably well as a library. But it may make sense to build some of them into TypeScript.
The only types that are cumbersome to use are:
Assert<B>
is verbose and affected by name clashes (see next subsection).assertType<T>(value)
only checks if value
is assignable to T
not if its type is equal to T
. That makes it less useful for testing.There are two ways in which we can use Assert
:
// Single type-level assertion
type _ = Assert<Equal<T1, T2>>;
// Multiple type-level assertions
type _2 = [ // (A)
Assert<Equal<T1, T2>>,
Assert<Extends<T1, T2>>,
];
These patterns have two downsides:
_
once (as you can see in line A where we had to use the name _2
).Name clashes could be avoided by:
_
to be used multiple times.type = Assert<Equal<T1, T2>>; // require `void` or `void[]`?
However, support could go even further and help reduce verbosity:
type! Equal<T1, T2>; // result must be `true`
type test Equal<T1, T2>; // result must be `true`
Another option is type assert
, but given that TypeScript already uses assert
for type guards and assertion functions, that could be problematic.
Sometimes when writing code that runs at the type level, it would be useful if we could fail at compile time. Currently, we can only do that via extends
constraints for type parameters. More explicit support could look like this:
// Doesn’t work: Assert requires its argument to be `true`
type AssertEqual<T1, T2> = Assert<Equal<T1, T2>>;
// Maybe:
type AssertEqual<T1, T2> = Equal<T1, T2> extends true ? true : fail;
Many utility types return never
when fail
would have been a better option – e.g.:
type First<T extends Array<unknown>> =
T extends [infer F, ...unknown[]]
? F
: never
;
@ts-expect-error
The functionality provided by the CLI tool ts-expect-error
could be built into TypeScript. The question is how do we check if an error message is as expected?
ts-expect-error
checks if the provided text is a prefix of the actual error message. That has worked well for me: If an error message changes, I simply update it in my tests.
Related GitHub issue: “Allow specifying diagnostic code in ts-expect-error”