Modules, declaration files and declaration merging
What a .d.ts file is, how TypeScript finds the types for an import, why type-only imports vanish, and how merging lets you extend someone else's types.
After this lesson you can
- Explain what a declaration file contains and where TypeScript looks for one
- Choose a moduleResolution setting and predict how package exports are matched
- Use import type and say what it changes in the emitted JavaScript
- Extend an existing interface or module with declaration merging
Declaration files
A .d.ts file holds types only: signatures, interfaces, declared
values, and no implementations. It describes JavaScript that exists
somewhere else — a compiled library, a global a script tag provides, a
package written in plain JavaScript.
// analytics.d.ts
declare function track(event: string, props?: Record<string, unknown>): void;
declare module "legacy-pdf" { // types for an untyped package
export function render(html: string): Promise<Uint8Array>;
}
For a package, TypeScript finds the types in the package itself (its
package.json types field or a types condition in exports), or in a
separate @types/<name> package from DefinitelyTyped. tsc writes
.d.ts files for your own code when declaration is on — that is how a
TypeScript library ships its types to JavaScript consumers.
Type-only imports disappear
Types are erased, so an import used only for types has nothing to import
at runtime. import type makes that explicit and guarantees the import
is removed. In the example the module does not even exist, and the code
runs:
// This module does not exist. A type-only import is erased completely,// so nothing tries to load it at runtime.import type { Invoice } from "./no-such-module"; export function run(): string { const draft: Partial<Invoice> = {}; return `ran fine with ${Object.keys(draft).length} keys`;}A plain import { thing } from "./no-such-module" used as a value fails
in this sandbox with Cannot find module './no-such-module' before any
code runs. Under the verbatimModuleSyntax setting, imports without
type are kept exactly as written, so marking type-only imports becomes
mandatory.
Without that setting, the compiler elides on its own: an imported name the
file uses only as a type is dropped, and a declaration with no name left
is removed entirely — taking any side effect of that module with it. A
bare import "./polyfills"; names nothing and is always kept.
How an import is resolved
moduleResolution decides how import "x" becomes a file:
node16/nodenext— Node's own rules. In ES modules a relative import needs its extension, written as.jseven though the source file is.ts(the import names the file that will exist after compiling).package.jsonexportsis honoured.bundler— for code that a bundler processes:exportsis honoured, but extensionless relative imports are allowed.node10(formerlynode) — the old CommonJS rules; ignores .
// A function and a namespace with the same name merge into one value:// a callable function that also carries properties.function price(amount: number): string { return `${amount.toFixed(2)} ${price.currency}`;} namespace price { export const currency = "GEL"; export function free(): string { return price(0); }} // Two interface declarations merge into one shape.interface Settings { theme: string;}interface Settings { fontSize: number;} export function run(): unknown[] { const s: Settings = { theme: "dark", fontSize: 14 }; // both halves required return [price(12.5), price.free(), typeof price, Object.keys(price), s];}Merging is how you extend types you do not own. Module augmentation
adds to an interface inside a package, and declare global adds to a
global one (both must sit in a file that is itself a module):
export {};
declare global {
interface Window { analytics: { track(event: string): void } }
}
Common mistakes
- Re-exporting a type as if it were a value.
export { User } from "./types", whereUseris only an interface: a compiler that sees one file at a time cannot tell, and emits a re-export of a name that does not exist at runtime.isolatedModulesreports it;export type { User }fixes it. - Extensionless relative imports under
nodenext. In ES modules Node requires the extension; write./util.jsin the.tssource. typeslisted afterimportinexports. Theimportcondition matches first, so TypeScript uses whatever declaration sits beside the JavaScript file (possibly a stale or wrong one) and reachestypesonly when there is none.- Hand-written that drifts. A declaration file is a promise about JavaScript nobody type-checks against it; generate it from source when you can.
Exercise · Pro
Which imports survive compilation
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade