19.5 Writing .d.ts Files for Untyped JavaScript Libraries
Alright, let’s roll up our sleeves and do some community service. You’ve found a brilliant JavaScript library that does exactly what you need. There’s just one problem: it’s from the era before TypeScript was cool, and it has no types. Your code is now a screaming parade of any types, and your autocomplete has given up and gone home. This is where you step in and write a Declaration File (.d.ts) to bring this library into the light.
Think of a .d.ts file as a formal introduction. You’re introducing your TypeScript compiler to this wild, untyped JavaScript library. You’re saying, “Hey, I know this guy looks a bit rough, but trust me, this is his API, and he’s actually quite reliable.” You’re writing a contract that says, “This library will have a function called doTheThing that takes a string and returns a number.” TypeScript will then trust you and enforce that contract, giving you all the sweet, sweet type safety and IntelliSense we crave.
You don’t need to re-implement the library; you’re just describing its shape. It’s like writing the table of contents for a book you’ve already read.
The Basic Anatomy of a Declaration File
Your mission is to describe what exists in the global scope once this library is loaded. We primarily use declare statements to do this. Let’s say our fictional library, coolJSLib, has a function that greets a user.
// coolJSLib.d.ts
declare module 'coolJSLib' {
export function greet(name: string): string;
}
Boom. You’ve just typed your first library. Now, when you import { greet } from 'coolJSLib';, TypeScript knows that greet is a function that takes a string and returns a string. No more any. Feel that? That’s the feeling of your code not breaking at runtime.
Dealing with Different Export Patterns
JavaScript libraries are a creative bunch when it comes to exporting things. You’ll need to match their pattern.
A Default Export:
declare module 'libraryWithDefault' {
const awesomeFunction: (input: number) => string;
export default awesomeFunction;
}
A CommonJS/Node.js-style Export:
This is where they attach everything to module.exports. You’ll see this all over the npm registry.
declare module 'oldSchoolLib' {
function calculateSomethingComplex(a: number, b: number): number;
namespace calculateSomethingComplex { // This is how you add properties to a function
let someConstant: number;
}
export = calculateSomethingComplex; // This is the key syntax for CommonJS
}
Describing the Weird Stuff: Namespaces and Global Pollution
Some libraries don’t play nice with module systems. They just load up and dump a global variable onto your window object. You have to tell TypeScript about this global variable.
Let’s say a script tag adds window.SuperWidget globally.
// global.d.ts or super-widget.d.ts
declare namespace SuperWidget {
interface Config {
color: string;
isCool: boolean;
}
function create(config: Config): void;
}
Now, anywhere in your project, you can just call SuperWidget.create({ color: 'red', isCool: true }) and TypeScript will know what you’re talking about and, crucially, check the object you’re passing in.
The Art of the Shim: When You Have to Extend Existing Types
Here’s a classic real-world headache. A library adds a plugin that tacks a new method onto a built-in type, like String or Array. This is both incredibly useful and architecturally questionable. To type this, you need to augment the existing TypeScript interface.
Imagine a string-extensions library that adds a toSnakeCase() method to all strings.
// string-extensions.d.ts
// Ensure we're in a module. Adding an import/export does this.
export {}; // This line makes this file a module, preventing errors.
declare global { // This is how we poke into the global scope
interface String {
toSnakeCase(): string;
}
}
This code tells TypeScript, “In addition to all the methods you know, every String also has this one.” It’s a bit of a power move, but it’s exactly what you need.
Best Practices and Pitfalls
- Start Shallow: Don’t try to perfectly type the entire massive library on day one. Start with the functions and objects you are actually using. A partial but accurate definition is infinitely better than a complete but wrong one.
- Use
anySparingly, but Use It: If a function can literally take anything,anyis correct. If it should only take a string but you’re lazy,anyis a crime. There’s a middle ground:unknownis often a better, safer choice thananywhen you’re unsure. - The
// @ts-ignoreTrap: Resist the urge to just ignore errors you can’t figure out. That’s a runtime bug waiting to happen. The whole point is to find these errors! Go back to the library’s docs or source code to understand what the type should actually be. - Test Your Declarations: Write some typed code that uses your new definitions. If your IDE is happy and
tsccompiles without error, you’re probably on the right track. If not, your definition is wrong and needs fixing.
It’s thankless work, but someone’s gotta do it. And when your definition file is rock solid, you get to be the hero who took a chunk of the wild west JavaScript ecosystem and made it a safe, civilized place for TypeScript to live.