Concept
The beginner framing: not all JavaScript code TypeScript needs to understand was written in TypeScript, a plain JS library, a global variable injected by a <script> tag, or a Node built-in all need some way to tell TypeScript what shape they have, without TypeScript ever compiling their actual implementation.
The precise mental model: a declaration file (.d.ts) contains only type information, no implementation, no runtime code at all. It's TypeScript's mechanism for describing the shape of JavaScript that exists (and runs) independently of TypeScript's own compilation.
// mylib.d.ts, pure type declarations, ZERO implementation code
export function add(a: number, b: number): number;
export interface Config {
timeout: number;
}Confirmed structurally: a .d.ts file never contains a function body for an exported function, only its signature, there's nothing to compile to JavaScript, since a .d.ts file's entire purpose is describing types for code that lives (and executes) elsewhere.
declare module: typing an entirely untyped package
// my-untyped-lib.d.ts
declare module "my-untyped-lib" {
export function doThing(x: number): string;
}// usage.ts, now fully type-checked, even though the actual
// "my-untyped-lib" package has NO TypeScript types of its own
import { doThing } from "my-untyped-lib";
doThing(5); // ✅ type-checked against the declaration aboveConfirmed by writing exactly this declaration and successfully importing/using the (fictional) package with full type safety, declare module "package-name" lets you describe an entire package's public API without touching its source, which is exactly what's needed for a JavaScript-only npm dependency that ships no types of its own.
Ambient declarations: augmenting the global scope
// globals.d.ts
declare global {
interface Window {
myAnalytics: { track(event: string): void };
}
}
export {};// usage.ts, window.myAnalytics is now a known, typed global
window.myAnalytics.track("page_view");Confirmed by compiling this pattern against the DOM lib: declare global { ... } lets a declaration file extend global scope types, here, adding a property to the existing Window interface (using the same interface declaration merging covered in Interfaces, now applied across the global scope rather than within a single file). The trailing export {} is a real, necessary detail, it makes the file a module (required for declare global to work as an augmentation rather than a full redeclaration), while intentionally exporting nothing.
@types packages and DefinitelyTyped
npm install --save-dev @types/lodashimport _ from "lodash"; // now fully typed, even though lodash itself ships plain JSMany popular JavaScript-only packages have their types maintained separately by the community, published under the @types/ npm scope, sourced from the DefinitelyTyped project, installing @types/lodash alongside lodash gives you the exact same declare module mechanism shown above, just pre-written and maintained by someone else rather than hand-authored for every untyped dependency.
Augmenting third-party module types
// augment.d.ts, adding a property to an EXISTING module's exported type
import "express";
declare module "express" {
interface Request {
userId?: string; // added by your own auth middleware
}
}Declaration merging isn't limited to interfaces declared in the same file, a declare module block re-opening an already-typed third-party module's namespace merges new members into its existing exported types. This is the standard pattern for adding custom properties (like req.userId, set by your own middleware) to a library's built-in types without forking or patching the library itself.
isolatedDeclarations, stable since TypeScript 5.5
// WITHOUT isolatedDeclarations: return type can be left implicit
export function getConfig() {
return { timeout: 3000 }; // return type INFERRED, not written
}// WITH isolatedDeclarations enabled: this now REQUIRES an explicit return type
export function getConfig(): { timeout: number } {
return { timeout: 3000 };
}Confirmed present as a stable compiler flag in this repo's TypeScript 5.9.3: isolatedDeclarations requires every exported function/value to have an explicit type, specifically so each file's .d.ts output can be generated by looking at that one file alone, without needing to type-check the entire program first. This matters for build performance in large codebases, declaration generation can run in parallel, per file, and be handled by faster, non-tsc tools, rather than requiring a full, sequential whole-program type-check just to emit .d.ts files.
Try It
Predict the outcome before checking the solution.
// augment.d.ts
declare module "express" {
interface Request {
userId?: string;
}
}If this file is included in a project's compilation, but nothing ever explicitly imports it, does the augmentation still take effect?
Solution
It depends on how the file is structured. A .d.ts file containing ONLY a declare module augmentation (no top-level import/export of its own) is treated as a global script, and TypeScript picks it up automatically as long as it's included in the program (via tsconfig.json's include, or simply being present in the compiled file set), no explicit import needed. However, if the file also has its own top-level import/export statements (making it a module itself), the augmentation typically needs an explicit import "express"; line at the top (as shown in the Concept section) to ensure the module being augmented is actually loaded and its types are available to merge into.
Implement It Yourself
Write a minimal declaration file for a small, fictional untyped utility library, then use it:
// string-utils.d.ts
declare module "string-utils" {
export function truncate(input: string, maxLength: number): string;
export function slugify(input: string): string;
export const VERSION: string;
}// usage.ts
import { truncate, slugify, VERSION } from "string-utils";
console.log(truncate("Hello, world!", 5)); // fully type-checked call
console.log(slugify("My Blog Post"));
console.log(VERSION);
truncate("test", "5"); // ❌ error, "5" isn't a number, caught despite the library having no types of its ownThis is the exact mechanism that makes typing any JavaScript-only dependency possible, describe the shape once, in a .d.ts file, and every usage site gets full type-checking against that description, entirely independent of whether the actual library implementation was ever written in TypeScript.
Under the Hood
Ambient global augmentation and third-party module augmentation both rely on the same declaration merging mechanism introduced for same-file interfaces in Interfaces, this topic is that mechanism's most powerful, most practically useful application, extended across file and even package boundaries. And isolatedDeclarations's explicit-return-type requirement connects directly back to the inference-first philosophy from TypeScript Basics, it's a deliberate, narrow exception to "let inference handle it," justified specifically by the build-performance win it enables for .d.ts generation.
Common Mistakes
1. Writing implementation code inside a .d.ts file
// mylib.d.ts
export function add(a: number, b: number) {
return a + b; // ❌ .d.ts files are TYPE-ONLY, this doesn't belong here
}A .d.ts file describes shapes; it never contains actual runtime logic. Implementation always lives in a regular .ts/.js file elsewhere.
2. Forgetting export {} when augmenting global scope from a file that otherwise has no exports
// globals.d.ts
declare global {
interface Window { myThing: string; }
}
// ❌ missing `export {}`, without it, this file may not be treated as a module correctlyThe empty export {} is a small but functionally necessary detail for declare global augmentations to work as intended in many configurations.
3. Assuming every JavaScript package needs a hand-written declare module
declare module "lodash" { /* ... */ } // ❌ unnecessary, @types/lodash already existsBefore hand-authoring a declaration file for a dependency, check whether a maintained @types/ package already covers it, DefinitelyTyped covers an enormous number of popular JS-only packages already.
Best Practices
- Check for an existing
@types/package before hand-writing declarations for a third-party dependency, most popular libraries are already covered. - Use module augmentation (not forking) to extend a library's types for legitimate custom additions (like middleware-set request properties), this keeps your changes isolated and doesn't require patching the library's source.
- Keep hand-authored
.d.tsfiles minimal and accurate, only declare what's actually used and verified, rather than speculatively typing an entire untyped library's full surface area upfront. - Consider
isolatedDeclarationsfor large monorepos specifically where.d.tsgeneration build time has become a measurable bottleneck, it's a real, if narrow, lever for that specific problem.
Performance Tips
isolatedDeclarationsis specifically a build-performance feature, enabling per-file, parallelizable.d.tsgeneration that doesn't require a full whole-program type-check first, which can meaningfully speed up builds in large codebases with many packages.- Declaration files themselves have zero runtime cost regardless, like all TypeScript types, they're consumed entirely at compile time and contribute nothing to the shipped JavaScript.
