|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/module-augmentation
$cat docs/typescript-—-module-augmentation-&-declaration-merging.md
updated Today·22 min read·published

TypeScript — Module Augmentation & Declaration Merging

TypeScriptDeclarationsAdvancedAdvanced🎯Free Tools
Introduction

Declaration merging and module augmentation let you extend existing types — yours or a dependency's — without forking packages. Used well, they model plugins, theme tokens, and Express Request fields. Used poorly, they create invisible global coupling.

This guide covers interface merging, namespace merging, declare module augmentation, declare global, ambient modules, and safe patterns for library authors and app teams.

info

Prefer exporting extensible interfaces from your own packages. Augment third-party modules only when the library documents an extension point.
Declaration Merging Basics

TypeScript merges multiple declarations with the same name in the same scope when the combination is legal. Interfaces merge freely; namespaces merge; some mixed merges (class + namespace, enum + namespace) are supported.

interface-merge.ts
TypeScript
1interface User {
2 id: string;
3}
4interface User {
5 email: string;
6}
7const u: User = { id: "1", email: "a@b.c" }; // merged
8
9interface Box {
10 value: string;
11}
12// interface Box { value: number } // ERROR — conflicting types
13
DeclarableMerges withNotes
interfaceinterfaceMembers combine; functions overload
namespacenamespaceValues + types across files
classnamespaceStatic side / nested types
enumnamespaceAdd functions beside enum
type aliasDoes not merge

warning

Type aliases never merge. If you need extension, start with an interface or provide an explicit augmentation hook.
Module Augmentation

To add fields to a module's exported interface, write a declare module "pkg" block that re-opens the interface. The file must be a module (contain top-level import/export).

express.d.ts
TypeScript
1import "express-serve-static-core";
2
3declare module "express-serve-static-core" {
4 interface Request {
5 userId?: string;
6 requestId: string;
7 }
8}
9
10// In middleware
11import type { Request, Response, NextFunction } from "express";
12
13export function attachRequestId(req: Request, _res: Response, next: NextFunction) {
14 req.requestId = crypto.randomUUID();
15 next();
16}
17

Augmenting your own package

theme-aug.ts
TypeScript
1// packages/ui/src/theme.ts
2export interface ThemeTokens {
3 colorPrimary: string;
4 radius: string;
5}
6
7export const defaultTheme: ThemeTokens = {
8 colorPrimary: "#3178C6",
9 radius: "8px",
10};
11
12// apps/web/theme-augmentation.d.ts
13import "@acme/ui";
14
15declare module "@acme/ui" {
16 interface ThemeTokens {
17 colorAccent: string;
18 }
19}
20

best practice

Document extension interfaces in README. Name them clearly (e.g. ThemeTokens) and avoid augmenting random exported types.
Global Augmentation

Use declare global inside a module to add to the global scope — for example custom JSX, Window, or Node globals.

global.d.ts
TypeScript
1export {}; // ensure this file is a module
2
3declare global {
4 interface Window {
5 __APP_CONFIG__: {
6 apiBase: string;
7 featureFlags: Record<string, boolean>;
8 };
9 }
10
11 namespace NodeJS {
12 interface ProcessEnv {
13 DATABASE_URL: string;
14 NODE_ENV: "development" | "production" | "test";
15 }
16 }
17}
18
19window.__APP_CONFIG__ = {
20 apiBase: "/api",
21 featureFlags: {},
22};
23

danger

Global augmentation is ambient forever in the compilation. Prefer module-scoped types unless the value truly is global.
Ambient Modules

When a JS package has no types, declare a minimal ambient module so imports type-check. Prefer DefinitelyTyped or the package's own types when available.

shim.d.ts
TypeScript
1declare module "legacy-widget" {
2 export interface WidgetOptions {
3 el: HTMLElement;
4 label?: string;
5 }
6 export class Widget {
7 constructor(options: WidgetOptions);
8 render(): void;
9 destroy(): void;
10 }
11 const legacyWidget: typeof Widget;
12 export default legacyWidget;
13}
14
15declare module "*.css" {
16 const classes: Record<string, string>;
17 export default classes;
18}
19
20declare module "*.svg" {
21 const url: string;
22 export default url;
23}
24
TechniqueUse when
declare module "x"Untyped dependency
declare module "x" { const x: any; export = x }CJS default export legacy
Triple-slash referenceRare — prefer project references / imports
paths to local stubPatching a bad published type
Namespace Merging Patterns
ns-merge.ts
TypeScript
1enum HttpStatus {
2 Ok = 200,
3 NotFound = 404,
4}
5
6namespace HttpStatus {
7 export function isSuccess(code: HttpStatus): boolean {
8 return code >= 200 && code < 300;
9 }
10}
11
12HttpStatus.isSuccess(HttpStatus.Ok);
13
14class ApiClient {
15 constructor(public base: string) {}
16}
17
18namespace ApiClient {
19 export type Method = "GET" | "POST" | "PUT" | "DELETE";
20 export interface Response<T> {
21 data: T;
22 status: number;
23 }
24}
25
26type R = ApiClient.Response<string>;
27
Plugin / Hook Pattern

Libraries that expect user augmentation should export empty interfaces as extension points and consume them in public APIs.

plugin.ts
TypeScript
1// library
2export interface AppPlugins {
3 // intentionally empty — users augment
4}
5
6export type PluginName = keyof AppPlugins & string;
7
8export function usePlugin<K extends PluginName>(
9 name: K,
10): AppPlugins[K] {
11 return getRegistry()[name];
12}
13
14function getRegistry(): AppPlugins {
15 return {} as AppPlugins;
16}
17
18// app
19declare module "@acme/app" {
20 interface AppPlugins {
21 analytics: { track(event: string): void };
22 auth: { userId: string | null };
23 }
24}
25
🔥

pro tip

Constraint keyof AppPlugins & string avoids number/symbol key noise when the interface is empty.
Pitfalls & Conflicts
pitfalls.ts
TypeScript
1// 1. Augmentation file is not a module → becomes global script, breaks isolation
2// Fix: add `export {}`
3
4// 2. Wrong module name (augmenting "express" vs "express-serve-static-core")
5// Read the library's type entrypoints
6
7// 3. Conflicting member types across packages
8// interface Req { userId: string } vs { userId: number } → hard errors
9
10// 4. Augmenting a type alias
11// export type Config = { ... } // cannot augment — use interface
12
13// 5. Forgetting to include .d.ts in tsconfig include
14
📝

note

Keep augmentation files in a dedicated types/ folder listed in include / files.
Checklist
StepDone?
Identify extension interface (not type alias)
Confirm module specifier matches package types
Ensure augmentation file is a module
Avoid any in ambient stubs — model minimally
Document required augmentations for consumers

Module augmentation is a power tool: small surface, explicit ownership, and tests that fail if the augmentation drifts.

Summary

Merge interfaces and namespaces when co-authoring types; use declare module to extend dependencies; use declare global sparingly; ship ambient modules only as temporary shims.

best practice

If you control both packages, prefer shared exported interfaces over cross-package augmentation.
Practice Snippets

Snippet 1

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-1.ts
TypeScript
1// Practice 1 for module-augmentation
2type _Probe_1 = unknown;
3const _ok_1 = true as const;

Snippet 2

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-2.ts
TypeScript
1// Practice 2 for module-augmentation
2type _Probe_2 = unknown;
3const _ok_2 = true as const;

Snippet 3

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-3.ts
TypeScript
1// Practice 3 for module-augmentation
2type _Probe_3 = unknown;
3const _ok_3 = true as const;

Snippet 4

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-4.ts
TypeScript
1// Practice 4 for module-augmentation
2type _Probe_4 = unknown;
3const _ok_4 = true as const;

Snippet 5

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-5.ts
TypeScript
1// Practice 5 for module-augmentation
2type _Probe_5 = unknown;
3const _ok_5 = true as const;

Snippet 6

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-6.ts
TypeScript
1// Practice 6 for module-augmentation
2type _Probe_6 = unknown;
3const _ok_6 = true as const;

Snippet 7

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-7.ts
TypeScript
1// Practice 7 for module-augmentation
2type _Probe_7 = unknown;
3const _ok_7 = true as const;

Snippet 8

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-8.ts
TypeScript
1// Practice 8 for module-augmentation
2type _Probe_8 = unknown;
3const _ok_8 = true as const;

Snippet 9

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-9.ts
TypeScript
1// Practice 9 for module-augmentation
2type _Probe_9 = unknown;
3const _ok_9 = true as const;

Snippet 10

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

module-augmentation-10.ts
TypeScript
1// Practice 10 for module-augmentation
2type _Probe_10 = unknown;
3const _ok_10 = true as const;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
How the Compiler Finds Augmentations

Augmentations must be part of the compilation graph. If a .d.ts file is excluded by exclude, or lives outside include, merges silently do not apply — symptoms look like “property does not exist” on types you thought you extended.

tsconfig-types.json
JSON
1{
2 "compilerOptions": {
3 "typeRoots": ["./node_modules/@types", "./types"],
4 "types": ["node"]
5 },
6 "include": ["src/**/*", "types/**/*.d.ts"]
7}

warning

Setting types explicitly limits automatic @types inclusion. List what you need, and keep app augmentations in include rather than relying on typeRoots alone.

Debugging missing merges

debug-aug.ts
TypeScript
1// 1. Ensure file is a module
2export {};
3
4// 2. Log the module name you import in app code — augment THAT string
5import type {} from "express-serve-static-core";
6
7declare module "express-serve-static-core" {
8 interface Request {
9 /** set by attachRequestId middleware */
10 requestId: string;
11 }
12}
13
14// 3. Quick probe
15import type { Request } from "express";
16type Probe = Request["requestId"]; // should be string
Guidance for Library Authors

Ship export interface extension points. Avoid requiring consumers to augment implementation classes. Publish example augmentation files in docs. Keep major-version stability for interface member types you expect users to merge.

author-hook.ts
TypeScript
1/** @public Extension point — augment to register widgets */
2export interface WidgetRegistry {}
3
4export function renderWidget<K extends keyof WidgetRegistry>(
5 name: K,
6 props: WidgetRegistry[K],
7): void {
8 // ...
9}
10
11// Consumer:
12// declare module "@acme/widgets" {
13// interface WidgetRegistry {
14// chart: { series: number[] };
15// }
16// }
17
DoDon't
Empty interface hooksAugment private types
Document module specifierRely on path deep imports
Semver interface fields carefullyChange member types casually
Deep Dive — Module Augmentation

This deep dive expands Module Augmentation with production-oriented recipes. Skim the tables, then implement one recipe in a scratch file under strict mode.

info

Keep npx tsc --noEmit green while experimenting — type errors are the curriculum.
Recipe — Triple slash vs import

Recipe 1: Triple slash vs import. Adapt names to your domain; keep the type relationships intact.

refs.ts
TypeScript
1/// <reference types="node" />
2import type {} from "express-serve-static-core";
3
📝

note

Verify recipe 1 by hovering inferred types and attempting an intentional misuse.
Recipe — Merge overload

Recipe 2: Merge overload. Adapt names to your domain; keep the type relationships intact.

overloads.ts
TypeScript
1interface Doc {
2 save(): Promise<void>;
3}
4interface Doc {
5 save(force: boolean): Promise<void>;
6}
7
📝

note

Verify recipe 2 by hovering inferred types and attempting an intentional misuse.
Recipe — Augment JSX

Recipe 3: Augment JSX. Adapt names to your domain; keep the type relationships intact.

jsx.d.ts
TypeScript
1export {};
2declare global {
3 namespace JSX {
4 interface IntrinsicElements {
5 "my-widget": { label: string };
6 }
7 }
8}
9
📝

note

Verify recipe 3 by hovering inferred types and attempting an intentional misuse.
Recipe — Patch bad types

Recipe 4: Patch bad types. Adapt names to your domain; keep the type relationships intact.

patch.d.ts
TypeScript
1declare module "awkward-lib" {
2 export function doThing(x: string): number;
3}
4
📝

note

Verify recipe 4 by hovering inferred types and attempting an intentional misuse.
FAQ

Does this replace runtime checks?

No. TypeScript erase at compile time. Pair with Zod or hand validation at boundaries.

How do I migrate gradually?

Enable strict flags one at a time, fix a package, then expand. See the migration guide.

What about performance?

Prefer project references and narrow include globs. Avoid enormous declaration merges in hot paths of the checker.

QuestionShort answer
Runtime cost of brands/variance annotations?Zero — compile-time only
Need emit?Often noEmit / bundler handles emit
CI gate?tsc --noEmit + ESLint type-checked
Checklist
ItemStatus
Strict tsconfig enabled
Boundaries validated at runtime
Public generics documented for variance
Lint type-checked rules on
No casual any / ts-ignore

best practice

Turn this checklist into a PR template for TypeScript-heavy changes.
Summary

You covered Module Augmentation end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.

🔥

pro tip

Teach teammates the failure modes first — correct code is easier after you have seen the bugs.
$Blueprint — Engineering Documentation·Section ID: TS-MODAUG·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.