Declaration Merging and Module Augmentation
TL;DR
TypeScript can merge multiple declarations with the same name in the same scope — interfaces, namespaces, sometimes function + namespace pairs. Combined with declare module, this lets you extend third-party types (Express’s Request, Vite’s ImportMeta, framework prop types) without forking them. The two big senior topics: when interface merging fires (and why type aliases don’t), and how to safely augment a third-party module.
In depth
What’s declaration merging?
When TS encounters two declarations with the same name in the same scope, certain kinds merge into a single combined declaration. The most common case: two interfaces.
interface User { id: number }
interface User { name: string }
const u: User = { id: 1, name: 'Ada' } // mergedA type alias declared twice is always an error — type is closed.
What can and can’t merge?
| Pair | Result |
|---|---|
interface + interface |
merge |
namespace + namespace |
merge |
interface + namespace |
merge (namespace adds static-side properties) |
class + namespace |
merge (namespace adds static-side members) |
function + namespace |
merge (namespace adds static-side properties to function) |
enum + namespace |
merge (extending an enum with helpers) |
type + anything |
error — type aliases don’t merge |
When would you want an interface to be open?
Two cases dominate:
- You ship a library and want users to extend it — Express’s
Request, React’sJSX.IntrinsicElements, Vite’sImportMeta. - You import multiple files that each augment a global — global ambient types, polyfills.
// In a library:
export interface RequestContext {
userId?: string
}
// In a user app file:
declare module 'mylib' {
interface RequestContext {
tenantId?: string
}
}Now RequestContext has both userId and tenantId. With type, this would have been impossible without modifying the library.
What’s module augmentation?
declare module 'name' { ... } lets you add to an existing module’s type — typically to extend a third-party API surface.
Classic Express example — typing req.user from auth middleware:
// types/express.d.ts
import 'express'
declare module 'express-serve-static-core' {
interface Request {
user?: { id: string; role: string }
}
}Now anywhere in the app, req.user is typed without casting. The import 'express' at the top brings the original types into scope so the augmentation merges, not redefines.
Common augmentation targets to know
- Express —
Request,Responseshapes. - Vite —
ImportMetaEnvfor typingimport.meta.env.VITE_*. - Next.js —
NodeJS.ProcessEnvfor typingprocess.env.*. - CSS / asset imports — declare modules for
*.module.css,*.svg,*.pngso import statements type-check. - Theme providers — Emotion, styled-components, MUI — declare
Themeso thethemecallback param is typed. - i18n libraries — augment
Resourcesso translation keys are autocompleted and validated.
Example: typing Vite env vars.
// vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_FEATURE_FLAG: 'on' | 'off'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}What’s global augmentation, and how is it different?
Module augmentation lives inside declare module '...'. Global augmentation lives inside declare global { ... } — used inside a module file (one with at least one import/export) to add to the global scope.
// types/global.d.ts
export {} // makes this file a module
declare global {
interface Window {
myAppVersion: string
}
}Now window.myAppVersion is typed everywhere. The empty export {} is what makes the file a module (otherwise declare global errors).
Where do .d.ts files belong in a project?
Two common patterns:
- A
types/folder with.d.tsfiles, referenced viatsconfig.jsonincludeortypeRoots. - Co-located next to the code they augment (e.g.
src/foo/foo.d.ts).
Either works. TS picks them up as long as they’re in the compilation. For augmentation to take effect, the file must be part of the build — not just referenced from node_modules.
What’s the difference between declare module 'name' and declare module '*'?
declare module 'lodash-extra'— augments (or declares) a specific module.declare module '*.svg' { const src: string; export default src }— declares a pattern; used to type non-JS imports like SVG, CSS, images.
declare module '*.svg' {
const content: string
export default content
}
// Now this works:
import logo from './logo.svg' // logo: stringNamespace merging — when is it useful?
Adding static members to a class or function:
function greet(name: string) { return `hi, ${name}` }
namespace greet {
export const version = '1.0.0'
export const author = 'me'
}
greet('Ada') // function call
greet.version // '1.0.0' — typed
greet.author // 'me'A pattern occasionally seen in older libraries.
How do you augment a library’s union type to add a new variant?
You can’t. Union types via type are closed. The library has to expose an extension point (an open interface, a generic, a registry pattern) for you to add to. Modern libraries use a registry pattern — an open interface keyed by string for clients to add to.
// Library:
export interface CommandRegistry {}
// Client:
declare module 'mylib' {
interface CommandRegistry {
'app/refresh': { force: boolean }
}
}
// Library uses: type CommandName = keyof CommandRegistryThis is how TanStack Router, tRPC, and similar achieve user-defined type extensions.
Gotchas / edge cases
- Augmentation requires the file to be a module — at least one
importorexport(orexport {}as a hack). - Re-declaring vs merging — if you
declare module 'foo' { interface Bar {} }without importing first, TS treats it as a new module declaration that replaces the original — often subtly broken. typealiases don’t merge — second declaration is an error.- Augmentation order matters across files — multiple files augmenting the same module all merge, but the order of resolution can affect autocompletion behaviour.
- Conflicts in merged interfaces are an error —
interface User { id: number } interface User { id: string }errors. namespaceis mostly legacy — for modern code, prefermodule;namespacesurvives for type-level merging and ambient declarations.- Don’t put augmentations in random source files — keep them in dedicated
*.d.tsfiles so contributors find them.
What a senior is expected to say
A junior treats third-party types as fixed. A senior knows that the right way to type req.user on Express isn’t a cast or as — it’s declare module 'express-serve-static-core' { interface Request { user?: User } }. The senior also knows the registry pattern (open interface keyed by string) is the modern way libraries (TanStack Router, tRPC, typesafe i18n) let users contribute to typing — and uses it themselves in shared internal libraries.
Cross-references
- React component prop typing (often combined with augmentation for styled-system themes): Typing React Components
- TS pitfalls (type vs interface): TypeScript Pitfalls — any/unknown/never, type vs interface, and Other Senior Traps