Testing static types in TypeScript

[2022-11-28] dev, typescript
(Ad, please don’t block)

When it comes to TypeScript code:

  • There are many options for testing its behavior at runtime.
  • There are far fewer options for testing its compile-type types.

In this blog post, we look at the latter.

Why would we want to test types?  

Consider a utility type such a the built-in Pick<Type, Keys>:

interface Person {
  first: string;
  last: string;
}

type Friend = Pick<Person, 'first'>;
// type Friend = {
//   first: string
// }

The three lines at the end serve two purposes:

  • They demonstrate how Pick works.
  • We can also use them to check if the result Friend is as intended (think manual unit test for types).

In both cases, we’d profit from an automated check:

  • If we demonstrate something, we could be sure that there are no typos.
  • If we want to test code, automation is important.

Type testing can also help us when JavaScript constructs such as functions have complicated types.

Simple solutions  

We can test a type by checking if a value is assignable to it (line A) or via the satisfies operator (line B):

interface Person {
  first: string;
  last: string;
}

type Friend = Pick<Person, 'first'>;

const friend1: Friend = { // (A)
  first: 'Robin',
};

({
  first: 'Flo',
}) satisfies Friend; // (B)

However, the previous tests only check that Friend doesn’t have more properties than the ones we are using. It doesn’t prevent Friend from having fewer properties. For example, TypeScript is fine with the following code:

type Friend = {};

const friend1: Friend = {
  first: 'Robin',
};

({
  first: 'Flo',
}) satisfies Friend;

Testing via code  

Some type testing libraries implement type checks in TypeScript and lets us use them in our code.

tsafe  

The library tsafe lets us check types via the type parameter of a function assert() (line A):

import { assert } from 'tsafe/assert';
import type { Equals } from 'tsafe';

interface Person {
  first: string;
  last: string;
}

assert<Equals< // (A)
  Pick<Person, 'first'>,
  { first: string }
>>();

@esfx/type-model/test  

Package @esfx/type-model has a module test with the generic type Test for type checks:

import { Test, ExpectType } from '@esfx/type-model/test';

// test suite
type _ = [
  Test<ExpectType<
    Pick<Person, 'first'>,
    { first: string }
  >>,
];

tsd  

tsd is a tool for running tests against .d.ts files. It looks similar to the previous two approaches, but performs custom compilation to check its type assertions.

concat.d.ts:

declare const concat: {
	(value1: string, value2: string): string;
	(value1: number, value2: number): number;
};
export default concat;

concat.test-d.ts

import {expectType} from 'tsd';
import concat from './concat.js';

expectType<string>(concat('foo', 'bar'));
expectType<number>(concat(1, 2));

How to implement an equality check for types  

If you want to implement an equality check for types yourself, there is a StackOverflow question whose answers have useful information.

Testing via comments  

Another approach for testing types is to write expected types in comments and use a tool to compare those types against actual types.

dtslint  

dtslint is a tool that is part of the DefinitelyTyped-tools. Using it looks like this:

function myFunc(n: number): void {}

// $ExpectType void
myFunc(123);

eslint-plugin-expect-type  

Using the ESLint plugin eslint-plugin-expect-type looks similar to dtslint:

function myFunc(n: number): void {}

// $ExpectType void
myFunc(123);

This plugin also supports twoslash syntax (^?):

//===== Single-line annotations =====
const square = (x: number) => x * x;
const four = square(2);
//    ^? const four: number

//===== Multi-line annotations =====
const vector = {
  x: 3,
  y: 4,
};
vector;
// ^? const vector: {
//      x: number;
//      y: number;
//    }

Checking for errors  

Sometimes we want to check that the wrong input produces errors. In JavaScript unit testing that is done (e.g.) via assert.throws(). This section examines the equivalents for type testing.

tsd  

With tsd, we check for errors via the function expectError().

misc.d.ts:

export declare class MyClass {}

misc.test-d.ts

import {expectError} from 'tsd';
import {MyClass} from './misc.js';

expectError(MyClass());
  // We forgot `new`:
  // “Value of type 'typeof MyClass' is not callable.”

This approach has two downsides:

  • TypeScript shows an error at compile time (e.g. during editing).
  • We don’t see what the error is that we are expecting. This makes this approach less useful for explanations (e.g. in documentation). It can also hide errors that we are not expecting.

dtslint  

Checking for errors with dtslint looks like this:

function myFunc(n: number): void {}

// @ts-expect-error
myFunc('abc');

Using @ts-expect-error has the benefit that TypeScript won’t complain at compile time. Alas, we still don’t see the error message.

eslint-plugin-expect-type  

Error checking with eslint-plugin-expect-type:

function myFunc(n: number): void {}

// $ExpectError
myFunc('abc');

The downsides are: TypeScript complains at compile time and we don’t see the error message.

Idea: improving error checking via @ts-expect-error  

For my book, “Tackling TypeScript”, I have written a simple type error checking tool (which, alas, is not ready for public consumption at the moment). It checks if the error message after @ts-expect-error matches what TypeScript would report without the annotation. That looks as follows (source of this example):

class Color {
  name: string;
  private branded = true;
  constructor(name: string) {
    this.name = name;
  }
}
class Person {
  name: string;
  private branded = true;
  constructor(name: string) {
    this.name = name;
  }
}

const person: Person = new Person('Jane');

// @ts-expect-error: Type 'Person' is not assignable to type
//   'Color'. Types have separate declarations of a private
//   property 'branded'. (2322)
const color: Color = person;

This time, TypeScript won’t complain and we see the error message.

If anyone is interested: I have created a Gist with all examples in “Tackling TypeScript” where I use this kind of error checking.

Testing via code vs. testing via comments  

My impression so far:

  • Code tests work better for testing code:

    • IDEs complain immediately: No special tool is needed to perform the checks.
    • The tests are more robust across TypeScript versions.
  • Comment tests work better for testing examples (e.g. embedded in Markdown):

    • The syntax looks nicer.
    • We need a special tool for checking/running the examples anyway.
    • The tests being more fragile is less of an issue. I’d even consider it a bonus if a checking tool forced me to update the syntax as TypeScript evolves.

Source of this blog post  

Thanks for all the replies in this Mastodon thread! They provided crucial information for this blog post.