Skip to main content

Declaration merging in TypeScript

Declaration merging in TypeScript is the compiler's ability to combine multiple declarations of the same name into a single definition. Interfaces, namespaces, and enums support it. Type aliases do not.

Theory

TL;DR

  • interface User {} declared twice merges automatically; type User = {} declared twice fails with a duplicate identifier error
  • Think of interfaces as a shared doc anyone can append to, and types as a locked file nobody can edit
  • Use interfaces for public APIs and library types; types for unions, primitives, and fixed shapes
  • Module augmentation (extending third-party types like Express or Jest) relies entirely on this feature
  • No runtime cost: all type information is erased before JavaScript output

Quick example

typescript
interface User { name: string; } interface User { age: number; } interface User { email: string; } // TypeScript sees one merged type: // { name: string; age: number; email: string } const user: User = { name: "Alice", age: 25, email: "alice@example.com" }; // ✅ // This fails immediately: // type User = { name: string }; // type User = { age: number }; // ❌ Duplicate identifier 'User'

Three separate declarations become one. The compiler requires all properties at once.

Interface merging vs type aliases

Interfaces generate structural symbols that the compiler can intersect across files. Type aliases create opaque identifiers with no merge logic. This is intentional: interfaces are designed to be open and extensible, while type aliases represent a closed, final shape.

For library code, the difference is significant. If you ship an interface, consumers can extend it without touching your source. If you ship a type alias, they cannot.

Many teams default to interface for object shapes; others use type everywhere. Both approaches work. The choice comes down to whether you need extension (interface) or composition (type).

When to use declaration merging

  • Building a library or SDK: declare props as interfaces so consumers can augment them without forking your package
  • Module augmentation: patch third-party types like Express Request or Jest Matchers
  • Environment variables: extend NodeJS.ProcessEnv with your specific env keys
  • Namespace organization: split large namespaces across files while keeping them under one name
  • Union types, primitives, fixed shapes: use type here since merging adds no value and a closed shape prevents accidental extension

Merging rules

DeclarationCan merge with
interfaceinterface
namespacenamespace, class, function, enum
classnamespace
functionnamespace
enumenum, namespace
type aliasnothing

Enums can merge with other enums, but each value must have a unique key. Conflicts error at compile time.

How the compiler handles this

TypeScript scans all .ts files in the compilation unit, collects declarations by exact name (case-sensitive), and merges compatible ones into a single symbol table entry during semantic analysis. For interfaces, it intersects member types: if two declarations define the same property with incompatible types, the compiler errors at the conflicting declaration. No runtime overhead exists since types are fully erased before JavaScript output.

Module augmentation

This is where declaration merging does its heaviest lifting in real projects. The Express pattern is the most common example:

typescript
// types/express.d.ts (no import/export = ambient script = global scope) declare namespace Express { interface Request { user?: { id: string; role: "admin" | "editor" | "viewer"; }; } } // Now TypeScript knows about req.user in every handler app.get("/profile", (req, res) => { console.log(req.user?.id); // ✅ fully typed });

In an ES module file (one that has import or export at the top), you need declare global to reach the global scope:

typescript
// This file has imports, so it is a module import express from "express"; // ✅ declare global wraps the augmentation correctly declare global { namespace Express { interface Request { userId: string; } } }

Without declare global, the compiler treats the namespace as module-scoped and the augmentation applies to nothing. That is the most common bug with this pattern.

Namespace merging

Namespaces merge like interfaces, but exported functions with the same name must have compatible signatures:

typescript
namespace MyLib { export function log(msg: string): void { console.log("v1:", msg); } } namespace MyLib { // ❌ Error: incompatible with the signature above export function log(msg: string, level?: number): void { console.log("v2:", msg, level); } }

The fix is declaring overloads in one block:

typescript
namespace MyLib { export function log(msg: string): void; export function log(msg: string, level: number): void; export function log(msg: string, level?: number): void { console.log(msg, level); } }

This pattern appears frequently in @types/node, particularly for fs.promises.

Common mistakes

Expecting type to merge like interface

typescript
type Point = { x: number }; type Point = { y: number }; // ❌ Duplicate identifier 'Point'

Type aliases are closed by design. Switch to interface if you need merging, or use intersection: type Point = { x: number } & { y: number }.

Merging incompatible member types

typescript
interface Box { width: number; } interface Box { width: string; // ❌ Subsequent property declarations must have the same type }

The compiler requires exact type matches for the same property across merged declarations. If you need different shapes, use intersection types instead.

Forgetting declare global in ES modules

typescript
// This file has an import, so it is a module import express from "express"; // ❌ This augments nothing - namespace is module-scoped declare module "express" { interface Request { userId: string; } } // ✅ This works declare global { namespace Express { interface Request { userId: string; } } }

A .ts file with no imports or exports is an ambient script and augments the global scope automatically. Add any import and it becomes a module.

Enum value conflicts

typescript
enum Status { Active = 1, } enum Status { Active = 2, // ❌ Redeclaration of 'Active' with a different value }

Each declaration in a merged enum must use unique keys.

Real-world usage

  • DefinitelyTyped (@types/react): merges React.JSX.IntrinsicElements so custom element attributes can be added per project
  • Express middleware: declare global { namespace Express { interface Request { user?: User } } } gives req.user across the whole app
  • Node.js type shims: augments the global namespace for browser polyfills like process.browser
  • Jest custom matchers: declare module "@jest/expect" { interface Matchers<R> { toBeWithinRange(a: number, b: number): R } }
  • Vite env types: interface ImportMetaEnv is augmented in vite-env.d.ts per project for typed import.meta.env

One thing that trips up teams regularly: the augmentation file has import at the top, so it becomes a module, and the namespace never reaches the global scope. They spend an hour checking their code before realizing the file structure is the issue.

Follow-up questions

Q: Why does interface support merging but type does not?
A: Interfaces generate structural symbols that the compiler can intersect across declarations. Type aliases create opaque references with no merge logic. Interfaces are designed to be open for extension; type aliases are designed to be final.

Q: Can classes participate in declaration merging?
A: Classes cannot merge with other classes. But a class can merge with a namespace, which lets you attach static utilities or factory functions without modifying the class body itself.

Q: What happens when merged interface properties have incompatible types?
A: The compiler errors at the point of the conflicting declaration, not at usage. The error message names the property and shows both types.

Q: Does file order matter when merging?
A: For interfaces, no. The compiler scans all files holistically and the order of declarations does not affect the merged shape. For function overloads inside a namespace, the order of signatures does matter for resolution.

Q: How does declaration merging behave in ES modules vs CommonJS?
A: The module system does not affect how the compiler merges declarations. What matters is whether a .ts file is ambient (no imports or exports) or a module. For module files, use declare global to reach the global scope.

Q: (Senior) How does TypeScript 5.x handle triple-slash augmentation vs package.json typesVersions?
A: Triple-slash directives for augmentation are still supported but the recommended approach for library authors is typesVersions in package.json. It maps type entry points per TypeScript version without adding directives to the global namespace or requiring consumers to reference files manually.

Examples

Design system component props split across files

This pattern shows how a component library can distribute prop declarations across feature files without a single large type file that every team member edits:

typescript
// button.types.ts - base props interface ButtonProps { variant: "primary" | "secondary"; onClick: () => void; } // button.size.ts - adds sizing without touching the base file interface ButtonProps { size: "small" | "medium" | "large"; } // button.icon.ts - icon support is optional interface ButtonProps { icon?: string; iconPosition?: "left" | "right"; } // All three merge. TypeScript enforces all required props: function Button({ variant, size, onClick, icon }: ButtonProps) { // ... } // ✅ variant, size, onClick are required; icon is optional Button({ variant: "primary", size: "large", onClick: () => {} });

Each team can own their file. No merge conflicts in version control. The compiler assembles the full type from all three.

Express authentication middleware with typed req.user

typescript
// src/types/express.d.ts // No import/export here - this is an ambient script, so it's global declare namespace Express { interface Request { user?: { id: string; email: string; role: "admin" | "editor" | "viewer"; }; } } // src/middleware/auth.ts import { Request, Response, NextFunction } from "express"; export function requireAuth(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.split(" ")[1]; if (!token) { return res.status(401).json({ error: "Unauthorized" }); } // Token verified - attach user to request req.user = { id: "123", email: "user@example.com", role: "admin" }; next(); } // src/routes/profile.ts app.get("/profile", requireAuth, (req, res) => { res.json({ id: req.user?.id, role: req.user?.role }); // ✅ fully typed });

The .d.ts file has no imports, so it is ambient and applies globally. Add import express from "express" to the top and the augmentation breaks unless you wrap it in declare global { namespace Express { ... } }.

Namespace merging with overloads and a compiler error walkthrough

This example shows the correct and incorrect ways to extend a namespace with a function that has multiple call signatures:

typescript
// ❌ Wrong: separate declarations with conflicting signatures namespace Logger { export function log(msg: string): void { console.log(msg); } } namespace Logger { // Error: Property 'log' is incompatible between declarations export function log(msg: string, level: "info" | "warn"): void { console.log(`[${level}]`, msg); } } // ✅ Correct: declare all overloads in one block namespace Logger { export function log(msg: string): void; export function log(msg: string, level: "info" | "warn"): void; export function log(msg: string, level?: "info" | "warn"): void { if (level) { console.log(`[${level}]`, msg); } else { console.log(msg); } } export interface Config { prefix?: string; defaultLevel: "info" | "warn"; } } // A second namespace block can still add non-conflicting members namespace Logger { export function createPrefixed(prefix: string): (msg: string) => void { return (msg) => Logger.log(`${prefix} ${msg}`); } } Logger.log("Server started"); // ✅ Logger.log("Auth failed", "warn"); // ✅ const apiLog = Logger.createPrefixed("API"); // ✅ apiLog("Request received"); // ✅ logs: API Request received

The compiler errors on function name conflicts between namespace declarations because it cannot determine which implementation to use. Non-conflicting members (like createPrefixed and Config) merge without issues.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?