Guide to TypeScript Ambient Declarations

One of my favorite things about TypeScript is the ability to take full advantage of the entire JavaScript ecosystem. If your TypeScript code needs to use a library that was written in plan JavaScript, you can always write ambient declarations that describe the types that would have been there, had it been written in TypeScript.

Ambient Types

For example, if you had to use a npm package firetruck that looked like this

/**
* Orient the water nozzle and spray for 30 seconds
*
* @param theta nozzle horizontal rotation angle
* @param phi nozzle vertical angle
* @param pressure water pressure
*/
async function extinguish(theta, phi, pressure) {
await aimHose(theta, phi);
await sprayWater(pressure);
}
export default extinguish;

in your TypeScript app, you’re likely to get a lot of errors about modules not being found, and “implicit any” types that TypeScript can’t infer without more information.

The way you’d address this problem is to define some types within your project that describe this code. These types usually live in declaration files (*.d.ts) that contain only types — no values at all. Here’s what one might look like in our case

// firetruck.d.ts
declare function extinguish(theta: number,
phi: number,
pressure: number): Promise<void>;
export default extinguish;

Now, when we try to use the extinguish function from within some TypeScript code, we get type checking on the arguments, and know that the return value is a promise!

Typically these files get passed around between developers interested in this npm package for a while, and eventually someone decides to publish them on NPM so they can be shared just like other node.js modules. This almost certainly means committing them to the DefinitelyTyped repo, because TypeScript is explicitly set up to make types from this package very easy for users to consume and Visual Studio Code can automatically find and download them on-demand.

DefinitelyTyped

In our case, we’d open up a pull request in DefinitelyTyped creating a new folder like types/firetruck/. We’d take our firetruck.d.ts file, and copy it to types/firetruck/index.d.ts — indicating that we’re describing the primary entry point for the npm package.

There’s a LOT going on in this one repo — literally thousands of npm packages being worked on by thousands of contributors. You’ll end up interacting with dt-review-bot to facilitate getting everything ready for merge. Once your work is published, the bot will notify any contributors listed at the top of the *.d.ts files anytime relevant issues or pull requests are opened.

The headers for these *.d.ts files follow a specific format that is parsed as part of the type-publishing process. Here, we state a version number (more on this later), the minimum supported TypeScript version and any contributors who should be alerted to PRs/issues.

// Type definitions for firetruck 1.3
// Project: https://github.com/firetruck/firetruck#readme
// Definitions by: Mike North<https://github.com/mike-north>
// TypeScript Version: 2.4

Shortly after your code is merged, the types-publisher will publish any changes to npm as @types packages. Ours would end up being called @types/firetruck v1.3.0. Subsequent changes to the types will automatically increment the patch version number.

High-level tips for writing good type declarations

Hopefully I’ll find time to write more about this, but at a high level there’s one important principles that will help get newcomers started.

Inappropriately strict types are more annoying (and more destructive to the code of your users) than inappropriately loose types.

Let’s look at a very simple example:

declare function generateId(): string | number;

Consider a situation where the return type of this function is inappropriately wide (i.e., 100% of the time a string is returned). The likely outcome is that users might do something like

let id = ‘’ + generateId();

or apply a type guard that will never be used

let id = generateId();
if (typeof id === ‘string’) { // not necessary

}

With our knowledge that the type is too wide, we might see this as a tiny bit of defensive programming, but it’s certainly not going to cause runtime errors.

Now consider a situation where our generateId sometimes fails and returns null. Our types are not telling consumers anything about this, which could lead to serious problems.

let id = generateId();
if (typeof id === ‘string’) {
id.toLowerCase(); // 💥 Runtime Error
}

Here’s another example: consider a function that’s meant to disable some text component

declare function setDisabled(elem: HTMLInputElement): void;

If I wanted to use this with a <textarea>, I’d have to throw away all of my type safety to get things working

setDisabled(myTextArea as any);

Once these anys get into their code, new versions of the types that fix things are unlikely to do much good. The developer would have to be paying extremely close attention to know they can remove as any. The common outcome of this story is that the type declarations being inappropriately strict results in users punching tons of holes in their type safety.

Tests

Like any kind of code, ambient types need to have tests to protect against regression. You’ll want to follow the same practices you’re used to following for other types of code, like

  • Bug fixes should be accompanied by test cases that prove the effectiveness of the fix, and protect against future regression
  • Have positive and negative test cases
  • Test that the solution works, not the private internals of how you might have gotten there

What’s different about testing ambient types is the way you write them. We have no values to assert against, and no functions to call. Thankfully, our testing needs are much simpler! Everything we need to test has to do with the idea of type equivalence (i.e., whether values of type T are assignable to a variable of type V).

Writing a simple positive test case is easy. All you need to do is create a new TypeScript file and attempt an assignment

const result: Promise<any> = extinguish(30, 20, 100); // ✅

If this successfully compiles, it means that whatever extinguish returns is assignable to a Promise<any>. The extinguish() function really returns Promise<void> , so let’s alter our test

const result: Promise<void> = extinguish(30, 20, 100); // ✅

Hmm. This passes too. It turns out that Promise<void> and Promise<any> are assignable to each other (because any will match anything).

If we really want to make sure that the type system yells at anyone attempting to use the value this promise resolves to (this is what we’re saying by returning a Promise<void>) we have to involve another tool.

dtslint

The dtslint library runs TSLint on your typescript files (including declarations), and compares the list of errors found against comments in your source code. We could assert the type returned by our function using a comment

extinguish(30, 20, 100); // $ExpectType Promise<void> ✅

Because the type of the value is stringified and compared to our comment, we’re no longer using assignment as the mechanism behind the test — we’re directly comparing strings. If you changed the commented type to Promise<any> this test would fail

Another kind of test we could write would be an expected error.

extinguish(30, 20, 100).then(data => {
data.abc // $ExpectError ✅
});

If data is of type void, then attempting to read any property from it will cause a type error. If a line with $ExpectError is found where no type error occurs, dtslint will indicate a test failure. If you’ve ever used something like QUnit.throws or Chai’s throws this behavior will be familiar to you.

Here’s an example repo with a positive and negative test case applied to ambient types.

The rate at which TypeScript is becoming popular is truly amazing, and supplementing popular libraries with type information is a big part of what makes adoption easy. When done right, consumers will swear that these libraries were written in TypeScript from the start!

Permalink: https://mike.works/blog/post/guide-to-typescript-ambient-declarations-13d3c45