Adrien Gautier
Adrien Gautier

Adrien Gautier

How to write tests for TypeScript types?

How to write tests for TypeScript types?

And when do we need them?

Adrien Gautier's photo
Adrien Gautier
·Dec 10, 2021·

4 min read

Writing test is, essentially, making assertions. Most of the time, these assertions aim to check the logic we implemented. But types in TypeScript rely on their own inference logic, don't they? In this article, I am going to cover two use cases where TypeScript inference is not sufficient and type testing is useful. Then we will see how ts-expect can help to test types.

When do types tests are needed?

Writing Declarations files

Declarations files must be written manually for JavaScript modules with no types in order 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 types module under the @types alias on npm) recommend writing tests to prevent these mistakes.

Declarations files tests in DefinitelyTyped rely on the project's own 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 is(n: number) => void

// $ExpectType void
f(1);

// Can also write the assertion on the same line.
f(2); // $ExpectType void

// $ExpectError
f("one");

dtslint provides an environment that compares the definitions files with those tests.

For declarations files located on your own project, dtslint advices to use the lib tsd instead.

Double-check Type Assertion

In a properly declared environment, most of the types are properly inferred by TypeScript. But, sometimes, declaration and inference are not enough, and we need to do Type Assertion.

Using the as keyword:

const articles = JSON.parse(data) as Articles;

Using type predicates (custom type guards):

function isFish(pet: Fish | Bird): pet is Fish {
  return (pet as Fish).swim !== undefined;
}

Type Guards are listed in the Narrowing section. But I like to think of it as a kind of "controlled" Type Assertion.

By using as or is keyword, we dictate how TypeScript must apply an arbitrary type.

Arbitrary doesn't mean random or without constraints. The initial type must overlap, using as (or include using is), the asserted type.

These Type Assertions are on the "implementation" side, and, because the implementation can evolve over time, it is a good idea to prevent any regression using tests.

We saw that dtslint and tsd are meant to test declarations files. They can't really be used in this case (it seems though that a workaround is possible using a dummy declarations file).

expect-type could be interesting because it relies on assertion similar to jest and can be written next to unit tests. However, errors in the IDE are quite opaque and it is difficult to know if the test is wrong or the actual implementation.

The approach of ts-expect is I think the most interesting. As said in the README, this lib provides functions that do nothing at all! Using a clever set of TypeScript generics, the lib is able to raise errors on build time if types constraints are not satisfied. Because the "tests" rely on a basic type inference from TypeScript, errors are easy to understand.

Writing tests with ts-expect

As said, tests with ts-expect are just about: "if it compiles then it works". You just need to dedicate a command to compile your tests files (e.g: execute tsc with the --noEmit flag because you don't need compiled files).

expectType

The main function brought by the lib aims to test if the value passed to the function satisfies the type passed as an argument to the generic.

const one = 1;
expectType<number>(one);

expectType on its own does not check equality. Any subset of the given type will be valid.

TypeEqual

For equality, ts-expect provides a generic with two arguments:

const one = 1;
expectType<TypeEqual<number, typeof one>>(false);

Types 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:

const one = "one";
// @ts-expect-error
expectType<number>(one);

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)

Conclusion

ts-expect API is not perfect but it provides a simple way to write assertions about types. Combined with the @ts-expect-error directive, it should cover most of the use-cases, if not all. Writing tests for my own package Soit revealed some weaknesses in my type guards I have been able to fix. And it will, I am sure, prevent regression for future evolutions of the package.