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 any
s 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