Testing Types: An Introduction to dtslint

Dan Vanderkam
HackerNoon.com

--

Does something look odd about this unit test?

it('should square 4', () => {
square(4);
});

Sure. It’s not asserting anything! It doesn’t matter whether square is implemented correctly. So long as the function doesn't throw an exception, this test will pass.

This isn’t great. The test would be much better if it checked the return value of square(4):

it('should square 4' () => {
expect(square(4)).to.equal(16);
});

Crazy as the first example is, it’s exactly how the type declarations in DefinitelyTyped have traditionally been tested. It didn’t matter what the types were, so long as the type checker didn’t find any errors. Particularly in the presence of any types, this makes for some weak tests. Weak tests lead to imprecise and inaccurate typings, and they make refactoring type declarations scary.

Microsoft recently introduced a new tool, dtslint, which makes assertions in type declaration tests possible. The rest of this post explains how to use it to bring all the benefits of testing to type declaration files.

A test without dtslint

Here are a few lines from the underscore tests for _.pluck, which I've written about before:

Note the lack of assertions on the return type. What this is really checking is that there is a function named _.pluck and that it accepts a list and a string as parameters.

The return type should be string[], but it's any[]. Too bad! How can we make the test fail?

A test

dtslint to the rescue! To check the return type of the call to _.pluck, we can use an // $ExpectType assertion:

When we run tsc on this test it passes. But when we run dtslint on it we get the following:

ERROR: 2:1  expect  Expected type to be:
string[]
got:
any[]

Tada! Caught!

We can make the declaration precise using a mapped type:

Now we get the following output from dtslint:

Test with 2.8
Test with 2.7
Test with 2.6
Test with 2.5
Test with 2.4
Test with 2.3
Test with 2.2
Test with 2.1
Test with 2.0
Error: /Users/danvk/github/dtslint-post/types/index.d.ts:1:33
ERROR: 1:33 expect Compile error in typescript@2.0 but not in typescript@2.1.
Fix with a comment '// TypeScript Version: 2.1' just under the header.
Cannot find name 'keyof'.

The tests pass with TypeScript 2.1+, but not with TypeScript 2.0. This make sense since keyof was introduced in TypeScript 2.1. Before TS 2.1, it wasn't possible to type pluck this precisely. So our only real option is to require a newer version using the suggested comment:

This gets at another reason that type declarations are hard to maintain. There are actually three independent versions involved in type declarations:

  • The version of the library
  • The version of the typings
  • The version of the TypeScript compiler

FlowTyped chooses to explicitly model this, whereas DefinitelyTyped does not.

Refactoring with tests

Suppose we’re working with type declarations for lodash’s map function:

export function map<U, V>(array: U[], fn: (u: U) => V): V[];

You use this much like Array.prototype.map:

_.map([1, 2, 3], x => x * x);  // returns [1, 4, 9].

Lodash has no _.pluck function. Instead, it adds a variant of _.map:

We’d to model this in the type declarations, but it’s scary to alter the type of such an important function! This is one of the very best reasons to write tests: they let you refactor with confidence. dtslint lets you do the same with type declarations.

Here’s a dtslint test for _.map that covers both the old and new declarations:

Now we can add an overload to the declaration for map:

When dtslint passes, we can be confident that we've both added the new functionality and avoided changing existing behavior.

Testing callback parameters

Callbacks are pervasive in JavaScript and it’s important that type declarations accurately model their parameters. dtslint can help here, too: if we're careful about formatting, we can make assertions about the types of callback parameters.

_.map actually passes three parameters to its callback. This snippet tests that all of them have the correct types inferred:

If we change any of those $ExpectType lines, we'll get an error. (This is often a good sanity check!)

Testing the type of “this”

It’s famously hard to know what this refers to in JavaScript. But TypeScript can help! If a library manipulates this in its callbacks, then the type declarations should model that.

If you’ve made it this far, you won’t be surprised to find out that dtslint can help here, too! Just write a type assertion for this:

Conclusion

Dealing with inaccurate or imprecise type declarations can be one of the most frustrating aspects of working in TypeScript. They can introduce false errors or give you an unwarranted sense of confidence by introducing any types where you weren't expecting them.

Testing is the key to improving an existing code base, and dtslint brings many of these benefits to TypeScript's type language. It lets you pin down existing behavior so that you can refactor type declarations with confidence. It even lets you do test-driven development with type declaration files!

dtslint is already in use in the DefinitelyTyped repo today. So if you're writing type declarations, please write some type assertions! And if you're changing existing type declarations, please write assertions for the existing behavior.

It's my hope that, over the long run, dtslint will lead to dramatically higher quality type declarations for all TypeScript users. And that means a better TypeScript experience, even if you don't know that dtslint exists!

Check out this repo to see all the code samples from this post in action. If you’d like to use dtslint outside of DefinitelyTyped, check out Paul Körbitz’s great post.

If you enjoyed this post, you might also enjoy my book, Effective TypeScript: 62 Specific Ways to Improve Your TypeScript (O’Reilly 2019). This post forms the basis for Item 52: Be Aware of the Pitfalls of Testing Types.

--

--

Dan Vanderkam
HackerNoon.com

Software Developer @sidewalklabs, author of Effective TypeScript