Section 5 · Shipping TypeScript
Declaration Files and Module Augmentation
16 min4 min readCode playground
Lesson at a glance
Describe JavaScript you did not write with declaration files, type globals and environment variables, and extend existing interfaces and modules with declaration merging and module augmentation.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Not everything your TypeScript touches was written in TypeScript. Cartwheel’s checkout loads an analytics snippet with a <script> tag, uses an old internal shipping calculator written in JavaScript, reads environment variables through Vite, and runs on a lib that is a year behind the browsers it ships to. For each of these, you need to tell the compiler about code it cannot see. That is what declaration files and declare are for.
declare: it exists, trust me
declare describes something that exists at runtime but is defined somewhere else. It produces no JavaScript. A file ending in .d.ts contains only such declarations:
type AnalyticsEvent = 'checkout_started' | 'order_placed' | 'payment_failed';
declare const cartwheelAnalytics: {
track(event: AnalyticsEvent, props?: Record<string, string | number>): void;
};This file has no import or export, so it is a global script: everything in it is visible in every file of the project. Now cartwheelAnalytics.track('order_plaecd') is a compile error, which the snippet’s documentation never managed.
A declaration is an unchecked promise, just like the as casts from earlier sections. If the snippet’s real API differs, TypeScript will happily trust your version. Keep declarations small, write only the parts you use, and put a comment with the library version you checked them against.
Typing a JavaScript package
Before writing any declarations for a package, check the two places types usually come from. Many packages ship their own ("types" in their package.json, or .d.ts files beside the JavaScript). For others, the community maintains @types/ packages on DefinitelyTyped: npm install -D @types/legacy-shipping-calc if one exists.
For an internal package with neither, declare the module yourself:
declare module 'legacy-shipping-calc' {
export type Quote = { carrier: string; feeCents: number; days: number };
export function quote(country: string, weightGrams: number): Quote[];
}The shorthand declare module 'legacy-shipping-calc'; with no body also works, and makes every import any. Treat it as a temporary escape hatch during a migration, not as a solution.
Keep hand-written declarations together in one folder, such as types/, and make sure your tsconfig’s include covers it; a .d.ts file the compiler never sees does nothing, and the resulting “Could not find a declaration file for module” error is confusing the first time. Note too that this file is global, like the analytics one: a declare module 'name' block with a string name is how you describe a package from a script file.
Declaration merging
Some declarations with the same name combine instead of clashing. The one you will use most: interfaces merge.
interface CheckoutConfig { currency: string }
interface CheckoutConfig { giftWrap: boolean }
const config: CheckoutConfig = { currency: 'EGP', giftWrap: true }; // both requiredType aliases never merge; two type CheckoutConfig declarations are a “Duplicate identifier” error. This is the main practical difference between type and interface, and it is why libraries that expect to be extended expose interfaces.
Merging is how you fill gaps in the standard library. The playground in this course uses the ES2022 lib, so Array.prototype.findLast (ES2023) is missing from its types even though every current browser has it. Merging into the global Array<T> interface adds it:
interface Array<T> {
findLast(predicate: (value: T, index: number, array: T[]) => unknown): T | undefined;
}In a real project, the better fix is the right lib or target setting. Merging is for the cases where config cannot express what the runtime actually has, such as a polyfill you ship yourself.
It is also how Vite types your environment variables. Its client types declare a global ImportMetaEnv interface, and your project merges into it:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_PAYMENTS_KEY: string;
}Module augmentation
To add to a type that lives inside a module, open the module with declare module from a file that is itself a module:
import 'cartwheel-ui';
declare module 'cartwheel-ui' {
interface Theme {
brand: { primary: string; onPrimary: string };
}
}The Theme interface inside cartwheel-ui now has a brand field everywhere it is used. To add to the global scope from a module file, use declare global { interface Window { dataLayer: unknown[] } }.
In the exercise you will declare the analytics global, add findLast to the playground’s lib, and extend a config interface by merging. Last lesson: bringing a whole JavaScript codebase across.
Key takeaways
- A
.d.tsfile contains only types;declaredescribes something that exists at runtime but is defined elsewhere. - Check for built-in types or an
@types/package before writing declarations, and write only the parts of a library you use. - Interfaces with the same name in the same scope merge into one; type aliases never merge, which is why extensible library types are interfaces.
declare module 'pkg' { … }in a file that has an import or export augments that module; in a file without one it declares a whole new ambient module.declare global { … }inside a module adds to the global scope, for example a property onWindowor a field onImportMetaEnv.