Part 4 · 2 chapters · ~20 min
Declaration Merging and Modules
Interface and namespace merging, module and global augmentation with the inclusion rule, ambient declarations as unchecked promises and the drift they cause, and authoring declaration files for a library: generated from source, an explicit surface, the exports map, module semantics, type tests in CI and semver for types.
9
Declaration merging and ambient declarations
describing what is defined elsewhere
- Interface merging: same-named interfaces in one scope become one (later members first in overload order; conflicting members error). Type aliases do not merge, which is the practical interface-versus-type difference: a library expecting augmentation uses an interface.
- Module augmentation:
declare module "lib" { interface X { … } }in a module file merges into the library's types program-wide;declare globaladds to the global scope from a module. The file must be included by the program; an unreferenced .d.ts silently does nothing (the usual "why is my augmentation ignored"). - Namespace merging: a namespace attaches statics and nested types to a same-named function or class (
C.Options); enums merge. Namespaces for runtime code are a pre-module pattern to avoid; for attaching types in .d.ts files they remain idiomatic. - Ambient declarations:
declare constfor build-time defines,declare functionfor global scripts,declare module "*.svg"for asset imports, a baredeclare module "lib"to make an untyped import any for now. declare emits nothing: a promise about the runtime that nothing checks. - Where they live: a types directory included by tsconfig; a global.d.ts with no imports or exports is a script file (global), otherwise use declare global;
libdecides which built-in ambients exist; @types packages resolve automatically ("types": []restricts). - The drift: window.analytics typed present when the script failed; a wildcard module typed string after the bundler changed; @types two majors behind. None fail the build; all fail at runtime. The discipline: a comment on every ambient declaration naming what guarantees it, and a runtime check at the boundary for anything that can be absent (the Trust course part 7).
DECLARATION MERGING AND AMBIENT DECLARATIONS
interfaces that accumulate, namespaces that attach, and declare that describes what exists elsewhere
swipe the figure sideways, or tap expand for full screen
1/6
interface merging
Interface merging: interface Options { a: string } and later interface Options { b: number } produce { a: string; b: number }; later declarations' members come first in overload order; conflicting non-function members error. Type aliases do not merge (a duplicate alias is an error), which is the practical difference between interface and type for extensible contracts: a library that expects consumers to augment should use an interface.
10
Authoring .d.ts for a library
code
// a library's surface: explicit, generated, tested
// src/index.ts (the only entry): re-export what is meant; nothing else is reachable
export { createClient } from './client'
export type { Client, ClientOptions, Result } from './client' // consumers must be able to name these
// src/client.ts
export interface ClientOptions { baseUrl: string; timeoutMs?: number; retries?: number } // an interface: consumers may augment (merging)
export type Result<T, E = ClientError> = { ok: true; value: T } | { ok: false; error: E }
export interface Client { get<T>(path: string): Promise<Result<T>>; close(): void }
export function createClient(options: ClientOptions): Client { // explicit return type: the internal class never leaks
return new ClientImpl(options)
}
/** @internal */ export class ClientImpl implements Client { … } // stripInternal drops it from the .d.ts
// tests/types.test-d.ts: the contract, tested by the checker in CI
import { expectTypeOf } from 'expect-type'
import { createClient, type Result } from '../src'
const c = createClient({ baseUrl: 'x' })
expectTypeOf(c.get<number>('/n')).resolves.toEqualTypeOf<Result<number>>()
expectTypeOf(createClient).parameter(0).toHaveProperty('timeoutMs').toEqualTypeOf<number | undefined>()
// @ts-expect-error: retries must be a number
createClient({ baseUrl: 'x', retries: '3' })
// tsconfig.build.json: "declaration": true, "declarationMap": true, "stripInternal": true, "isolatedDeclarations": true
// package.json: "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "files": ["dist"]
// release: api-extractor diffs dist/index.d.ts against the last release; a removed or narrowed export is a majorthe declaration file is the contract
- Generate, never hand-write:
declaration: trueemits what the checker computed (declarationMapfor go-to-definition into your source); explicit return types on exported functions keep an internal shape from leaking into the contract and changing with the implementation. - The public surface: one entry file re-exporting the intended API; types consumers need exported (or they reconstruct them);
@internalwithstripInternal;isolatedDeclarations(5.5) to require explicit types on exports so declarations emit per file without the checker. - package.json:
typesfor a single entry, or anexportsmap with the types condition first (resolution takes the first match); a dual package needs .d.ts and .d.cts;typesVersionsonly if you must support old TypeScript. - Module semantics: ESM declarations with default and named exports; CJS with
export =; both in one file is the esModuleInterop headache. Decide the module shape (part 8) and generate declarations that match it. - Type tests (expect-type, vitest's expectTypeOf, tsd) run by the checker in CI: the widened return, the reordered overload and the broken inference are caught before a consumer finds them. A library without type tests has a contract nobody checks.
- Versioning: a .d.ts change is semver: removed exports, narrowed parameters, widened returns and renamed types are major; added optionals and new overloads minor; a fix to a wrong type is a patch that can still break someone (document it). api-extractor diffs the declaration surface per release (the Architecture course part 4's changesets, for types).
the exercise
Run
tsc --declaration on a package you publish and read the emitted index.d.ts as a stranger. Every internal name, every inferred shape and every any in it is a contract you did not mean to sign.AUTHORING .D.TS FOR A LIBRARY
the declaration file as a contract, generating it, shipping it, and the rules that keep it true
swipe the figure sideways, or tap expand for full screen
1/6
generation
Generation: tsc with declaration: true (and declarationMap: true so a consumer's go-to-definition lands in your source) emits a .d.ts per module from the type-checked source; the declarations are exactly what the checker computed, which is why explicit return types on exported functions matter: an inferred return type of an internal shape leaks the internal into the contract and changes when the implementation does.