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.
Interview Q&A
Q: 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' } // merged
A type alias declared twice is always an error — type is closed.
Q: 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 |
Q: 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.
Q: 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.
Q: 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
}
Q: 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).
Q: 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.
Q: 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: string
Q: Namespace 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.
Q: 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 CommandRegistry
This 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): 08_react_typing.md
- TS pitfalls (type vs interface): 10_typescript_pitfalls.md