Why we should all be testing our Typescript types

Frontend Nov 24, 2021

What a silly idea, to test types, right? You never hear about testing types in languages like C# or Dart, so why should TypeScript be any different? Because it is different.

Don’t get me wrong... TypeScript is still one of the best things that’s happened to JavaScript. But there are two traps that can at times make the type system more bothersome than helpful:

  1. Weak types, like any or object
  2. Complex types, such as generics, conditional types, recursive types, etc.

I believe testing can help avoid both these traps. But before I jump into that, let me first elaborate on how these traps make their way into the codebase in the first place.

Weak types

The main danger with the combination of weak and strong types is that you can get a false sense of type-safety.

If your codebase isn’t written in TypeScript from the get-go, the primary source of weak types will be your own JavaScript code. This is followed by deliberately suppressed errors in your TypeScript code.

External dependencies are another major source, and it’s nearly impossible to avoid. Libraries that were originally written in JS are the worst, but even those written in TS may follow less strict standards.

We use a lot of Lodash/fp, which is a good example. It has some type definitions, but it also has many weakly-typed fallbacks.

Take the get function for example. In this code, the variable foo will be typed correctly as a number:

import { get } from 'lodash/fp';
const foo = get('foo', { foo: 123 });

But not so if you use the built-in currying:

import { get } from 'lodash/fp';
const getFoo = get('foo');
const foo = getFoo({ foo: 123 });

In this case, you create a function getFoo that practically equates to (input: any) => any, and all the type-safety is gone.

Avoid “any” with ESLint

There are five rules provided by the TypeScript ESLint plugin, that practically ban the use of the most dangerous weak type:

I would strongly recommend enabling these rules.

But even without any, you’re not completely safe. Take this example:

const example1: Array<number> = [1, 2, 3];
const result1 = example1[123];

const example2: Record<string, number> = { foo: 123 };
const result2 = example2['bar'];

On first sight, you can see that both result1 and result2 will be undefined, but TypeScript will see them as numbers.

Unfortunately, this type of error is hard to catch even with type tests (unit tests might help).

Complex types

TypeScript is powerful; it allows us to define pretty crazy stuff – for example, combinations of generic, conditional, recursive and mapped types. It’s easy to make mistakes when using its advanced concepts.

However, the solution to this isn’t to avoid them as they’re incredibly useful, especially when you’re migrating a previously untyped JS code.

Let’s say we want to introduce a better typed function getFoo:

function getFoo<Input extends { foo: unknown }>(input: Input): Input['foo'] {
    return input.foo;
}

This function will hopefully fulfil these two assumptions:

  1. The input must be an object with a property foo.
  2. The return type will be the same as the type of the input’s property foo.

How can I be sure this is correct? Did I make any mistakes? What if I remove the type parameter and change the return type to (typeof input)['foo']? Would it still work?

To check assumptions about what a piece of code does, you need to write a unit test: a simple check to see “if you do this, you get that”. You can use a similar approach to test assumptions about how types will work.

That’s what type testing is about.

Microsoft dtslint

Although we don’t use it ourselves, I need to mention this: dtslint is the tool used to test definitions in the DefinitelyTyped repository.

Tests are written as TS files with special comments recognized by this tool.

It proved to be very hard to configure for our use case, where we wanted the test files collocated with the tested code. This tool is not meant for such a use case.

You should check it out if you plan to contribute to DefinitelyTyped, but it’s not something I’d recommend otherwise. Our tool of choice is TypeScript itself.

Testing types using TypeScript

You can start with no additional tooling. Simply create a TS file in your project and write down a code that you would expect to pass the check. You can also write a code that you expect to fail and mark it with a @ts-expect-error comment.

The special comment @ts-expect-error, unlike @ts-ignore, will result in an error if there's no error to suppress on the next line.

This way you can easily check the first assumption:

import { getFoo } from '../getFoo';

getFoo({ foo: 123 });
getFoo({ foo: 'abc' });
// @ts-expect-error
getFoo();
// @ts-expect-error
getFoo({ bar: 123 });

The assumption of the return type will get more complicated. The first thing that comes to mind is a test of assignability. It’s certainly better than nothing:

const test1: number = getFoo({ foo: 123 });
const test2: string = getFoo({ foo: 'abc' });

But here’s the catch: You can still fall into the trap of weak types because if the function returns any, it will pass. You can assign any to a number; you can assign any to almost anything (except to never).

Is it possible to test that something is typed as any? One way is to leverage the fact that any extends true and any extends false at the same time:

type IsAny<T> = T extends true
    ? T extends false
        ? true
        : never
    : never;
const result = getSomeValue();
const test: IsAny<typeof result> = true;

There would be an error on the last line if getSomeValue() had other return type than any. There are similar tricks to check that something isn’t any or that some types are equal (e.g. that the function returns number and not a numeric literal), etc.

But it starts looking ugly and not very readable. The type IsAny itself is quite complex, and as I stated before, it’s easy to make mistakes in such types.

Luckily there’s a library that encapsulates these kinds of tricks.

expect-type

NPM package expect-type by Misha Kaletsky provides a nice fluent API for checking type expectations.

The return type of our previously defined function getFoo can be tested easily:

import { expectTypeOf } from 'expect-type';
import { getFoo } from '../getFoo';

expectTypeOf(getFoo({ foo: 123 })).toEqualTypeOf<number>();
expectTypeOf(getFoo({ foo: 'abc' })).toEqualTypeOf<string>();

I would say this code is self-explanatory, and it can stay in the project to prevent us breaking the types in the future (sort of a regression test).

Conventions

You may choose different conventions; this is just our current approach.

We place the type tests in the same folder as our unit tests. That is the __tests__ folder next to the code being tested, only with a different extension - *.typetest.ts.

These files are never executed; they’re only type-checked. What this means is that the code doesn’t have to actually work.

Let’s say we want to test some Redux selectors. We don't need to initialize the whole application state; we can simply say that it exists using declare const:

declare const state: ApplicationState;

expectTypeOf(getBillsByAccountId(state, { accountId: 'a' })).toEqualTypeOf<Bill[]>();
expectTypeOf(getBillIdsByAccountId(state, { accountId: 'a' })).toEqualTypeOf<string[]>();

We don’t have any strict structure for these files. Sometimes we use scopes so we can use the same variable names in different contexts, sometimes it’s all in the file scope (like in this selectors example). If the test case isn’t obvious, we may provide comments, but often they aren’t necessary.

Conclusions

TypeScript is different from other typed languages because the type system is optional and most of the time you can’t avoid having some combination of weak and strong types. It’s also easier to introduce incorrect complex types, compared to other languages.

Testing the types can help regain some certainty. The motivation for this is similar to the motivation for unit tests.

There were times when programmers were testing their code using ad hoc scripts, which they then deleted when they were done, instead of turning them into automated tests. I think today we often do something similar with types in TS.

When we’re uncertain about a type, we should test it and keep the test. It’s easy, and your future self will thank you.


For more engineering insights shared by Mews tech team:

Tags

Václav Šír

Frontend developer at Mews.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.