loading...

TDD for TypeScript type definitions

webpapaya profile image webpapaya ・6 min read

I recently wanted to add TS type definitions to hamjest, my assertion library of choice. If you don't know hamjest yet you can check out my post on writing better test assertions. Without any experience in writing type definitions I wanted a way to verify my type definitions. After doing some research I realised that there is a tool called tsd which can be used to verify type definitions.

TDD in a nutshell

TDD is an iterative development process where every change in production code is triggered by a failing test. The process is divided into three parts, which are repeated after every iteration:

  • Write a failing test
    • watch the test fail
  • Write just as much code to make the test pass
    • watch the tests pass
  • Refactor the code

I won't get into much details in here as there are some great resources on this topic out there:

Getting started

First of all we need a very basic setup for TypeScript:

npm init
npm i tsd chokidar-cli -D
touch index.d.ts # types or implementation
touch index.test-d.ts # tests
echo "{\"lib\": [\"es2015\"]}" > tsconfig.json # add minimal tsconfig.json
./node_modules/.bin/chokidar "./**/*" -c "./node_modules/.bin/tsd && echo done" # start testing

To make this easier to understand we'll test the types of a math library which can do various arithmetic on numbers. After starting tsd and reading the docs of the library we see that there is a sum function which can take 2 arguments and returns the sum of those. So we could start with the first test in index.test-d.ts

// index.test-d.ts
import {expectType} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))

tsd contains a couple of assertions which can be used to verify ts types. In the given example we're using expectType to verify the return signature of the function. After saving we get back the following 2 errors:

// ✖  4:0   Parameter type number is not identical to argument type any.
// ✖  4:28  Property sum does not exist on type typeof import("/Users/tmayrhofer/Projects/hamjest/index").

The implementation could look like this:

// index.test-d.ts
import {expectType} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))

// index.d.ts
export function sum(a: number, b: number): number

From the documentation of the library we know that besides summing 2 numbers it’s possible to sum 3 numbers as well:

// index.test-d.ts
import {expectType} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3)) // <= new test case
// ERROR: ✖  7:38  Expected 2 arguments, but got 3.

The above error could be fixed like that:

// index.test-d.ts
import {expectType} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3))

// index.d.ts
export function sum (a: number, b: number, c?: number): number

Looking at the documentation again we see that an arbitrary amount of numbers could be summed by this function. So we'll add another test:

// index.test-d.ts
import {expectType} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3))
expectType<number>(myModule.sum(1, 2, 3, 4, /*...*/ 9)) // <= new test case
// ERROR: Expected 2 arguments, but got 9.

// index.d.ts
export function sum (a: number, b: number, c?: number): number

The type definition could be adapted like that:

// index.test-d.ts
import {expectType} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3))
expectType<number>(myModule.sum(1, 2, 3, 4, /*...*/ 9))

// index.d.ts
export function sum(...values: number[]): number

The documentation states that the sum function requires at least one argument or otherwise a runtime exception will be thrown. As we don't want the application to run into runtime exceptions we'll add another test case which should prevent a call to the sum function without any arguments:

// index.test-d.ts
import {expectType, expectError} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3))
expectType<number>(myModule.sum(1, 2, 3, 4, /*...*/ 9))
expectError(myModule.sum()) // <= new test case
// ERROR: ✖  16:0  Expected an error, but found none.

// index.d.ts
export function sum(...values: number[]): number

Our type could update to the following:

// index.test-d.ts
import {expectType, expectError} from 'tsd'
import * as myModule from '.'

expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3))
expectType<number>(myModule.sum(1, 2, 3, 4, /*...*/ 9))
expectError(myModule.sum())

// index.d.ts
type ArrayWithAtLeastOneElement<T> = {
  0: T
} & Array<T>

export function sum (...values: ArrayWithAtLeastOneElement<number>): number

The ArrayWithAtLeastOneElement type specifies a non empty array. We can add this type to our function and our tests are turning green again.

Another function this library exposes is called div. This function simply divides two numbers. Adding the first test could look like this:

// index.test-d.ts
import {expectType, expectError} from 'tsd'
import * as myModule from '.'

// ... tests for sum

expectType<number>(myModule.div(1, 2))
// ✖  18:0   Parameter type number is not identical to argument type any.
// ✖  18:28  Property div does not exist on type typeof import("/Users/tmayrhofer/Projects/hamjest/index").

The implementation should be similar to the first example of the add function. So we end up with the following:

// index.test-d.ts
import {expectType, expectError} from 'tsd'
import * as myModule from '.'

// ... tests for sum
expectType<number>(myModule.div(1, 2))

// index.d.ts
export function div(a: number, b: number): number

As dividing by zero is impossible we would like to catch this case by the type system itself. So the next test could look like the following:

// index.test-d.ts
import {expectType, expectError} from 'tsd'
import * as myModule from '.'

// ... tests for sum
expectType<number>(myModule.div(1, 2))
expectError(myModule.div(1, 0)) // <= new test case
// ERROR: ✖  19:0  Expected an error, but found none.

Fixing this test could be done by the following code:

// index.test-d.ts
import {expectType, expectError} from 'tsd'
import * as myModule from '.'

// ... tests for sum
expectType<number>(myModule.div(1, 2))
expectError(myModule.div(1, 0))


// index.d.ts
export function div<Divider extends number>(
  a: number,
  b: Divider extends 0 ? never : Divider
): number

So finally we end up with the following the following types and tests:

// index.d.ts
type ArrayWithAtLeastOneElement<T> = {
  0: T
} & Array<T>

export function sum (...values: ArrayWithAtLeastOneElement<number>): number
export function div<Divider extends number>(
  a: number,
  b: Divider extends 0 ? never : Divider
): number

// index.d.ts
expectType<number>(myModule.sum(1, 2))
expectType<number>(myModule.sum(1, 2, 3))
expectType<number>(myModule.sum(1, 2, 3, 4, /*...*/ 9))
expectError(myModule.sum())

expectType<number>(myModule.div(1, 2))
expectError(myModule.div(1, 0))

Conclusion

Using TDD for TS types makes it possible to refactor types without the fear of breaking them. Having a safe way to refactor types prevents accidental complexity. Working in baby-steps makes it possible to break down more complex types into easy to process chunks. Writing tests for types in the presented way is not 100% bulletproof as we can't be sure that our typings match the actual implementation. As the tests are simple TS/JS files those could still be executed so that the actual implementation could be verified as well. Especially for more complex libraries (eg. momentJS/jQuery) the TDD approach makes it easier to develop types. Despite enjoying this process to develop types like presented in this post, it should be applied only when there is no other way to alter the project. Having a comprehensive test suite written in TS would make it possible, to verify typings and the behaviour of the library with the same code.

If you want to learn more about TypeScript, TDD or maintainable software I'm always happy to have a chat via Twitter.

Discussion

markdown guide