TypeScript — Module Augmentation & Declaration Merging
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
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.
| 1 | interface User { |
| 2 | id: string; |
| 3 | } |
| 4 | interface User { |
| 5 | email: string; |
| 6 | } |
| 7 | const u: User = { id: "1", email: "a@b.c" }; // merged |
| 8 | |
| 9 | interface Box { |
| 10 | value: string; |
| 11 | } |
| 12 | // interface Box { value: number } // ERROR — conflicting types |
| 13 |
| Declarable | Merges with | Notes |
|---|---|---|
| interface | interface | Members combine; functions overload |
| namespace | namespace | Values + types across files |
| class | namespace | Static side / nested types |
| enum | namespace | Add functions beside enum |
| type alias | — | Does not merge |
warning
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).
| 1 | import "express-serve-static-core"; |
| 2 | |
| 3 | declare module "express-serve-static-core" { |
| 4 | interface Request { |
| 5 | userId?: string; |
| 6 | requestId: string; |
| 7 | } |
| 8 | } |
| 9 | |
| 10 | // In middleware |
| 11 | import type { Request, Response, NextFunction } from "express"; |
| 12 | |
| 13 | export function attachRequestId(req: Request, _res: Response, next: NextFunction) { |
| 14 | req.requestId = crypto.randomUUID(); |
| 15 | next(); |
| 16 | } |
| 17 |
Augmenting your own package
| 1 | // packages/ui/src/theme.ts |
| 2 | export interface ThemeTokens { |
| 3 | colorPrimary: string; |
| 4 | radius: string; |
| 5 | } |
| 6 | |
| 7 | export const defaultTheme: ThemeTokens = { |
| 8 | colorPrimary: "#3178C6", |
| 9 | radius: "8px", |
| 10 | }; |
| 11 | |
| 12 | // apps/web/theme-augmentation.d.ts |
| 13 | import "@acme/ui"; |
| 14 | |
| 15 | declare module "@acme/ui" { |
| 16 | interface ThemeTokens { |
| 17 | colorAccent: string; |
| 18 | } |
| 19 | } |
| 20 |
best practice
Use declare global inside a module to add to the global scope — for example custom JSX, Window, or Node globals.
| 1 | export {}; // ensure this file is a module |
| 2 | |
| 3 | declare 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 | |
| 19 | window.__APP_CONFIG__ = { |
| 20 | apiBase: "/api", |
| 21 | featureFlags: {}, |
| 22 | }; |
| 23 |
danger
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.
| 1 | declare 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 | |
| 15 | declare module "*.css" { |
| 16 | const classes: Record<string, string>; |
| 17 | export default classes; |
| 18 | } |
| 19 | |
| 20 | declare module "*.svg" { |
| 21 | const url: string; |
| 22 | export default url; |
| 23 | } |
| 24 |
| Technique | Use when |
|---|---|
| declare module "x" | Untyped dependency |
| declare module "x" { const x: any; export = x } | CJS default export legacy |
| Triple-slash reference | Rare — prefer project references / imports |
| paths to local stub | Patching a bad published type |
| 1 | enum HttpStatus { |
| 2 | Ok = 200, |
| 3 | NotFound = 404, |
| 4 | } |
| 5 | |
| 6 | namespace HttpStatus { |
| 7 | export function isSuccess(code: HttpStatus): boolean { |
| 8 | return code >= 200 && code < 300; |
| 9 | } |
| 10 | } |
| 11 | |
| 12 | HttpStatus.isSuccess(HttpStatus.Ok); |
| 13 | |
| 14 | class ApiClient { |
| 15 | constructor(public base: string) {} |
| 16 | } |
| 17 | |
| 18 | namespace ApiClient { |
| 19 | export type Method = "GET" | "POST" | "PUT" | "DELETE"; |
| 20 | export interface Response<T> { |
| 21 | data: T; |
| 22 | status: number; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | type R = ApiClient.Response<string>; |
| 27 |
Libraries that expect user augmentation should export empty interfaces as extension points and consume them in public APIs.
| 1 | // library |
| 2 | export interface AppPlugins { |
| 3 | // intentionally empty — users augment |
| 4 | } |
| 5 | |
| 6 | export type PluginName = keyof AppPlugins & string; |
| 7 | |
| 8 | export function usePlugin<K extends PluginName>( |
| 9 | name: K, |
| 10 | ): AppPlugins[K] { |
| 11 | return getRegistry()[name]; |
| 12 | } |
| 13 | |
| 14 | function getRegistry(): AppPlugins { |
| 15 | return {} as AppPlugins; |
| 16 | } |
| 17 | |
| 18 | // app |
| 19 | declare module "@acme/app" { |
| 20 | interface AppPlugins { |
| 21 | analytics: { track(event: string): void }; |
| 22 | auth: { userId: string | null }; |
| 23 | } |
| 24 | } |
| 25 |
pro tip
| 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
| Step | Done? |
|---|---|
| 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.
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
Snippet 1
Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 1 for module-augmentation |
| 2 | type _Probe_1 = unknown; |
| 3 | const _ok_1 = true as const; |
Snippet 2
Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 2 for module-augmentation |
| 2 | type _Probe_2 = unknown; |
| 3 | const _ok_2 = true as const; |
Snippet 3
Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 3 for module-augmentation |
| 2 | type _Probe_3 = unknown; |
| 3 | const _ok_3 = true as const; |
Snippet 4
Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 4 for module-augmentation |
| 2 | type _Probe_4 = unknown; |
| 3 | const _ok_4 = true as const; |
Snippet 5
Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 5 for module-augmentation |
| 2 | type _Probe_5 = unknown; |
| 3 | const _ok_5 = true as const; |
Snippet 6
Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 6 for module-augmentation |
| 2 | type _Probe_6 = unknown; |
| 3 | const _ok_6 = true as const; |
Snippet 7
Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 7 for module-augmentation |
| 2 | type _Probe_7 = unknown; |
| 3 | const _ok_7 = true as const; |
Snippet 8
Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 8 for module-augmentation |
| 2 | type _Probe_8 = unknown; |
| 3 | const _ok_8 = true as const; |
Snippet 9
Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 9 for module-augmentation |
| 2 | type _Probe_9 = unknown; |
| 3 | const _ok_9 = true as const; |
Snippet 10
Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.
| 1 | // Practice 10 for module-augmentation |
| 2 | type _Probe_10 = unknown; |
| 3 | const _ok_10 = true as const; |
note
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.
| 1 | { |
| 2 | "compilerOptions": { |
| 3 | "typeRoots": ["./node_modules/@types", "./types"], |
| 4 | "types": ["node"] |
| 5 | }, |
| 6 | "include": ["src/**/*", "types/**/*.d.ts"] |
| 7 | } |
warning
Debugging missing merges
| 1 | // 1. Ensure file is a module |
| 2 | export {}; |
| 3 | |
| 4 | // 2. Log the module name you import in app code — augment THAT string |
| 5 | import type {} from "express-serve-static-core"; |
| 6 | |
| 7 | declare module "express-serve-static-core" { |
| 8 | interface Request { |
| 9 | /** set by attachRequestId middleware */ |
| 10 | requestId: string; |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | // 3. Quick probe |
| 15 | import type { Request } from "express"; |
| 16 | type Probe = Request["requestId"]; // should be string |
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
Recipe 1: Triple slash vs import. Adapt names to your domain; keep the type relationships intact.
| 1 | /// <reference types="node" /> |
| 2 | import type {} from "express-serve-static-core"; |
| 3 |
note
Recipe 2: Merge overload. Adapt names to your domain; keep the type relationships intact.
| 1 | interface Doc { |
| 2 | save(): Promise<void>; |
| 3 | } |
| 4 | interface Doc { |
| 5 | save(force: boolean): Promise<void>; |
| 6 | } |
| 7 |
note
Recipe 3: Augment JSX. Adapt names to your domain; keep the type relationships intact.
| 1 | export {}; |
| 2 | declare global { |
| 3 | namespace JSX { |
| 4 | interface IntrinsicElements { |
| 5 | "my-widget": { label: string }; |
| 6 | } |
| 7 | } |
| 8 | } |
| 9 |
note
Recipe 4: Patch bad types. Adapt names to your domain; keep the type relationships intact.
| 1 | declare module "awkward-lib" { |
| 2 | export function doThing(x: string): number; |
| 3 | } |
| 4 |
note
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.
| Question | Short 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 |
| Item | Status |
|---|---|
| Strict tsconfig enabled | ☐ |
| Boundaries validated at runtime | ☐ |
| Public generics documented for variance | ☐ |
| Lint type-checked rules on | ☐ |
| No casual any / ts-ignore | ☐ |
best practice
You covered Module Augmentation end-to-end: core mental model, recipes, FAQ, and a ship checklist. Continue with related topics via PageNav.
pro tip
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.