19.3 Ambient Modules: declare module
Right, so you’ve met declare module. This is how we tell TypeScript, “Hey, trust me, this thing exists over there, in some other file or library, and here’s what its shape looks like.” You’re essentially drawing a map for the compiler to a treasure that you’ve already buried elsewhere. It’s the backbone of describing non-TypeScript libraries, but it’s also got some sharp edges if you’re not careful.
The most common, and frankly, the most sane use of declare module is for describing those classic JavaScript libraries that were written before anyone thought type safety was cooler than a flip phone. You use it to create an ambient module declaration.
Let’s say you have a venerable old library called calculator.js that you include via a <script> tag. It has a global function, addNumbers, but TypeScript, rightly so, has no idea what it is. Scream “I exist!” for it by creating a calculator.d.ts file.
// calculator.d.ts
declare module "calculator" {
export function addNumbers(a: number, b: number): number;
export const version: string;
}
Now, you can import it in your modern, type-safe code, and TypeScript will use your declaration as the source of truth.
// main.ts
import { addNumbers, version } from "calculator";
const result = addNumbers(5, 10); // TypeScript is perfectly happy.
console.log(`Using calculator version: ${version}`);
The Anatomy of a declare module
The structure is straightforward. The declare module keyword is followed by a string that is the name of the module. This name must match exactly what you (or the underlying module system) will use in the import statement. Inside the braces, you describe the exports. You can export functions, classes, interfaces, types, constants—the whole gang. Crucially, you’re not implementing anything here; you’re just describing a contract.
The Wild West: Wildcard (*) Module Declarations
Here’s where things get a little… creative. Sometimes you need to quickly shush TypeScript about files it doesn’t understand, like CSS modules or SVG imports, without writing a full, detailed declaration for each one. This is the “don’t bother me with the details” approach.
// styles.d.ts
declare module "*.module.css" {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module "*.png" {
const src: string;
export default src;
}
declare module "*.svg" {
import React = require("react");
const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
export default ReactComponent;
}
Now, you can import './App.module.css' and TypeScript will know it’s an object where the keys are strings. It’s a blunt instrument, but incredibly useful for unblocking yourself during a migration or when working with asset loaders from your bundler (Webpack, Vite, etc.).
The Subtle Pitfall: Module Augmentation vs. Declaration
Pay very close attention here, because this is a common “oh crap” moment. There’s a critical difference between declaring a module and augmenting one.
A declare module "foo" {} statement creates a new declaration or completely replaces an existing one if you’re not careful. If you write two declarations for the same module, the latter will override the former. This is rarely what you want.
If a module already has a declaration (e.g., from DefinitelyTyped’s @types/foo package) and you just want to add a few more things to it, you need to use module augmentation. This relies on a clever trick: you don’t use declare module "foo", you use declare module "foo" { ... } but you ensure your file is treated as a global script. The safer, more modern way is to use the same pattern but within a normal module file.
// foo-augmentation.d.ts
// This must be a global .d.ts file (no top-level import/export)
declare module "foo" {
// This INTERFACES with the existing "foo" declaration
export function myNewFunction(): void; // Augmenting the existing module
}
Or, if you’re in a module file (one with imports/exports), you do this:
// augmentation.ts
import "foo"; // This is crucial - it makes the augmentation ambient
declare module "foo" {
export function myNewFunction(): void;
}
The key insight is that the declare module block in this context is seen as an augmentation because TypeScript has already seen a declaration for “foo” (via the import). Get this wrong, and you’ll either see duplicate identifier errors or your augmentations will silently do nothing.
Best Practices: Don’t Go Overboard
My strong advice? Use declare module sparingly. Its primary job is for creating type definitions for third-party JS libraries. For your own code, you should almost always be writing actual .ts or .tsx files, not .d.ts files that describe them. Let TypeScript generate the declaration files for you—it’s much better at the job.
Reserve manual declare module for:
- Describing legacy JS libraries.
- Creating wildcard declarations for assets.
- Carefully augmenting existing third-party type definitions.
It’s a powerful tool, a escape hatch from the type system. And like any good escape hatch, you should know exactly where it is and how to use it, but you shouldn’t be crawling through it every day.