frontend / typescript / 07_declaration_merging_augmentation.md

Declaration Merging and Module Augmentation

5 min read source

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:

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

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

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

  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.

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