TypeScript type definitions can be quite complex. They need to be tested to avoid mistakes during implementation and refactoring.

Introducing the Example

In my last post, I showed how to use TypeScript to check translations and their usage at compile time. I would like to briefly recall this example.

I defined a type DeepKeysOf, which returns all possible nested keys from one object.

type DeepKeysOf<T, Key extends keyof T = keyof T> = Key extends string
  ? T[Key] extends string ? Key : `${Key}.${DeepKeysOf<T[Key]>}`
  : never;

If this type definition is used for the translate method now, the compiler can already point out faulty translations.

const translation = {
  dialog: {
    title: 'Bestätigung',
    description: 'Möchten Sie fortfahren?'
  }
}

type TranslationKey = DeepKeysOf<typeof translation>; // 'dialog.title' | 'dialog.description'

const translate = (translationKey: TranslationKey): string => {
  // omit the actual implementation
}

translate('dialog.title'); // compiles fine
translate('dialog.header'); // compiler error: Argument of type '"dialog.header"' is not assignable to parameter of type 'TranslationKey'.

The full example with more detailed explanations can be found in my previous post or in this TypeScript Playground.

Testing

Looking at the implementation, some issues stand out that need to be addressed:

  • The definition is quite complex and not obvious at first sight.
  • Check if the implementation works as expected, in the good case as well as in the bad case.
  • Refactorings should not lead to new errors.
  • Document the use of DeepKeysOf.

All these points apply in general to any code, not only to type definitions. The key is to write unit tests. And this is exactly what I want to do for TypeScript’s type definitions.

The big difference to usual unit tests is that type definitions exist only at compile time. This implies that the tests also have to be executed by the compiler rather than at runtime like all other unit tests. This sounds complicated, but is actually fairly easy. In the following I want to briefly introduce the possibilities with ts-expect-error and expect-type.

ts-expect-error

ts-excpect-error was introduces with TypeScript 3.9. This instructs TypeScript to expect a compiler error on the following line. If a line is preceded by a // @ts-expect-error comment, TypeScript suppresses the reporting of this error; but if there is no error, TypeScript reports that // @ts-expect-error was not necessary.

translate('dialog.title');

// @ts-expect-error
translate('dialog.header');

The actual type definition of DeepKeysOf can be tested without using the translate method:

// @ts-expect-error
const invalidKey: DeepKeysOf<typeof translation> = 'dialog.header';

A complete example can be found at TypeScript Playground.

This is a very simple approach and can be used without additional libraries. However, the solution is not very verbose, in unit test usually the expected result is explicitly given.

expect-type

expect-type is a library developed for exactly this purpose: Testing type definitions.

const translation = {
  dialog: {
    title: 'Bestätigung',
    description: 'Möchten Sie fortfahren?'
  }
}

type TranslationKey = DeepKeysOf<typeof translation>;

expectTypeOf<'dialog.title'>().toMatchTypeOf<TranslationKey>();
expectTypeOf<'dialog.header'>().not.toMatchTypeOf<TranslationKey>();

In my opinion expectTypeOf is a very smart solution, but it adds another dependency to the project.

Comments are welcome on Twitter or LinkedIn.