Frontend / Typescript / 07_declaration_merging_augmentation.md

Declaration Merging and Module Augmentation

Updated 5 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

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.

ts
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.

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:

  1. You ship a library and want users to extend it — Express’s Request, React’s JSX.IntrinsicElements, Vite’s ImportMeta.
  2. You import multiple files that each augment a global — global ambient types, polyfills.
ts
// 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:

ts
// 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

  • ExpressRequest, Response shapes.
  • ViteImportMetaEnv for typing import.meta.env.VITE_*.
  • Next.jsNodeJS.ProcessEnv for typing process.env.*.
  • CSS / asset imports — declare modules for *.module.css, *.svg, *.png so import statements type-check.
  • Theme providers — Emotion, styled-components, MUI — declare Theme so the theme callback param is typed.
  • i18n libraries — augment Resources so translation keys are autocompleted and validated.

Example: typing Vite env vars.

ts
// 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.

ts
// 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:

  1. A types/ folder with .d.ts files, referenced via tsconfig.json include or typeRoots.
  2. 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.
ts
declare module '*.svg' {
  const content: string
  export default content
}

// Now this works:
import logo from './logo.svg'   // logo: string

Namespace merging — when is it useful?

Adding static members to a class or function:

ts
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.

ts
// 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 import or export (or export {} 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.
  • type aliases 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 errorinterface User { id: number } interface User { id: string } errors.
  • namespace is mostly legacy — for modern code, prefer module; namespace survives for type-level merging and ambient declarations.
  • Don’t put augmentations in random source files — keep them in dedicated *.d.ts files 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

Further reading