Writing test is, essentially, making assertions. Most of the time, these assertions aim to check the runtime logic we implemented. But in some situations, we implement our own build-time logic: our own types. This article covers different use cases and tools we can use to write safer types.
Testing declarations files
Declarations files must be written manually for JavaScript modules with no types for them to be used in a TypeScript project.
Writing declarations is a tedious task and mistakes can be made. The DefinitelyTyped project (which provides many TypeScript declarations under the @types
alias on npm) recommends writing tests to prevent these mistakes.
Declarations files tests in DefinitelyTyped rely on the project's internal tool: dtslint. Tests are a bit different from what we are used to in a test framework like Jest. They are like regular TypeScript files, but annotated with comments that do the actual assertion:
import { f } from "my-lib"; // f(n: number) => void
// $ExpectType void
f(1);
// $ExpectError
f("one");
dtslint provides an environment that compares the declarations files with those tests.
For declarations files located on your project, DefinitelyTyped advises using the lib tsd instead. Both dtslint and tsd are meant to test declarations files only. In the next sections, we are going to explore other tools for other use cases.
Testing advanced types
Let's take the following custom type guard :
function isFish(animal: Fish | Bird): pet is Fish {
return (animal as Fish).swim !== undefined;
}
Like any other function, we would test its logic like so :
// Given
const someFish = new Fish();
// Then
expect(isFish(someFish)).toBe(true);
But in addition to runtime logic, custom type guards also bring build-time logic to the table: the type predicate. When used in a condition, this function can narrow its input to the type suggested after the keyword is
.
Using ts-expect
This can be tested using ts-expect. This tool offers a "dumb" function and two generics that raise errors at compilation time if type constraints are not satisfied.
import { expectType } from "ts-expect";
if(isFish(animal)) {
expectType<Fish>(animal);
}
In this test, we check that animal
is properly narrowed to Fish. If it compiles, that means the test is valid. Because this helper relies on a basic type inference from TypeScript, errors are easy to understand.
Because this logic is not meant to be executed, I would recommend writing it in dedicated test files. You would compile them using
tsc
with the--noEmit
flag to prevent any output.
Using expect-type
expect-type is also interesting because it features an API similar to jest.
import { expectTypeOf } from 'expect-type';
if(isFish(animal)) {
expectTypeOf(animal).toEqualTypeOf<Fish>();
}
This library can be confusing because you can either pass an object as an argument to the function or pass a type using the generic of the function. Errors can also be a bit confusing compared to ts-expect.
Assertions that should not be satisfied
It can be useful to test the opposite assertion: to expect that a type does not satisfy another. Typescript, since version 3.9, provides a very powerful directive:
if(isFish(animal)) {
expectType<Fish>(animal);
// @ts-expect-error
expectType<Bird>(animal);
}
If for any reason the line following the directive doesn't raise an error, the comment itself will raise the error: Unused '@ts-expect-error' directive. ts(2578)
. This can prevent your code to be "poisoned" with any
declarations.
While this directive can be used with both libraries, expect-type provides the
.not
word to do "negative assertions" :expectTypeOf(animal).not.toEqualTypeOf<Bird>();
tl;dr
You can use tsd to test your declarations files. If you want to test advanced types like custom type guards or bespoke generics, use can use ts-expect or expect-type. The @ts-expect-error
directive can also be used to make sure your types are strict enough.