TypeScriptExtending & Merging Interfaces

Extending & Merging Interfaces

TypeScript interfaces are more than just type contracts — they are composable building blocks. Two powerful mechanisms let you grow interfaces over time: extension (building new interfaces on top of existing ones) and declaration merging (adding to an interface after it has already been defined). Together they allow you to build rich, layered type hierarchies without duplicating code.

Single Interface Extension

The extends keyword lets one interface inherit all members of another and then add its own. Think of it as saying "everything in A, plus these extras."

TS
interface Animal {
  name: string;
  age: number;
}

interface Dog extends Animal {
  breed: string;
  bark(): void;
}

const myDog: Dog = {
  name: 'Rex',
  age: 3,
  breed: 'Labrador',
  bark() {
    console.log('Woof!');
  },
};

Dog automatically inherits name and age from Animal. You only need to define the new members (breed and bark). The resulting shape is the union of all inherited and own members.

Note
Extending an interface does not copy the members — at runtime there are no interfaces at all. Extension is purely a compile-time constraint that tells TypeScript what shape a value must satisfy.
Multiple Inheritance: Extending More Than One Interface

TypeScript allows a single interface to extend multiple parent interfaces, separated by commas. This is something regular classes cannot do (they can only extend one class), making interfaces especially powerful for type modelling.

TS
interface Serializable {
  serialize(): string;
}

interface Loggable {
  log(message: string): void;
}

interface ApiResponse extends Serializable, Loggable {
  statusCode: number;
  data: unknown;
}

// A value of type ApiResponse must satisfy ALL three interfaces
const response: ApiResponse = {
  statusCode: 200,
  data: { id: 1 },
  serialize() {
    return JSON.stringify(this.data);
  },
  log(message: string) {
    console.log(`[${this.statusCode}] ${message}`);
  },
};
Tip
Use multiple extension when your new type genuinely is both things — not just when you want the properties. If the relationship feels forced, prefer composition (having a property of that type) over inheritance.
Declaration Merging

TypeScript has a unique feature called declaration merging: if you declare two interfaces with the same name in the same scope, TypeScript automatically merges them into a single interface. Neither declaration replaces the other — both sets of members coexist.

TS
interface User {
  id: number;
  name: string;
}

// Later in the same file (or module) — same name, new members
interface User {
  email: string;
  createdAt: Date;
}

// TypeScript sees a single merged interface:
// { id: number; name: string; email: string; createdAt: Date }

const user: User = {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
  createdAt: new Date(),
};
Warning
Declaration merging only works with interface, not type. Two type aliases with the same name in the same scope produce a compile error.

The canonical use case for merging is extending your own interfaces incrementally — for example, adding feature-flagged fields without touching the original declaration.

Method overloads via merging

When the same method signature appears in both interface bodies, the result is an overloaded function. The most recently declared overload is checked first at the call site.

TS
// First declaration
interface Formatter {
  format(value: number): string;
}

// Second declaration — merges with the first
interface Formatter {
  format(value: string): string;
  format(value: Date): string;
}

// Merged result (second-declaration overloads first):
// format(value: string): string
// format(value: Date): string
// format(value: number): string

declare const fmt: Formatter;
fmt.format(42);          // OK -> string
fmt.format('hello');     // OK -> string
fmt.format(new Date());  // OK -> string
Module Augmentation: Merging with Third-Party Types

Module augmentation lets you merge into interfaces that live in an npm package — without touching the package itself. You open the module with a declare module block and extend the relevant interface inside.

Extending the global Window object

A very common pattern when integrating third-party SDKs (analytics, chat widgets, payment providers) that attach themselves to window:

TS
// src/types/global.d.ts  (or any .d.ts file included by tsconfig)

export {};   // make this a module so the declaration merges rather than re-declares

declare global {
  interface Window {
    analytics: {
      track(event: string, properties?: Record<string, unknown>): void;
      identify(userId: string): void;
    };
    __APP_VERSION__: string;
  }
}

// Now anywhere in your app:
window.analytics.track('page_view', { path: '/home' });
window.__APP_VERSION__;   // string — no more "does not exist on type Window" errors
Note
The export {} at the top is required. Without it the file is an ambient script and declare global is unnecessary. With it the file is a proper module, and you must explicitly opt back into the global scope with declare global.
Extending Express Request

When you attach custom data to the Express Request object (for example, after authentication middleware populates req.user), you need to tell TypeScript about those extra properties:

TS
// src/types/express.d.ts

import { User } from '../models/User';

declare module 'express-serve-static-core' {
  interface Request {
    user?: User;           // populated by auth middleware
    requestId: string;     // populated by request-id middleware
  }
}

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';

export function authenticate(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // TypeScript now knows req.user is valid — no casting needed
  req.user = verifyToken(token);
  next();
}

// src/routes/profile.ts
import { Request, Response } from 'express';

export function getProfile(req: Request, res: Response) {
  if (!req.user) {
    return res.status(401).json({ error: 'Not logged in' });
  }
  res.json({ profile: req.user });
}
Tip
Always augment express-serve-static-core, not express directly. The Request type you import from express actually originates in the core package, so that is where the merge must happen.
Extension vs. Intersection Types

Both extends and the & intersection operator combine type shapes, but they behave differently — especially around property conflicts.

Aspect

interface extends

type & intersection

Syntax

interface C extends A, B { }

type C = A & B & { }

Works with

interfaces only

interfaces, types, primitives

Declaration merging

Yes

No

Conflicting same-name property

Compile error

Results in never

Error messages

Clearer (names preserved)

Can be verbose

Augmentation / open types

Yes

No

Performance (large codebases)

Faster (cached)

Slower (re-evaluated)

TS
// Extension — TypeScript reports a clear, immediate error
interface A { value: string }
interface B extends A { value: number }
//                      ^^^^^ Error: type 'number' is not assignable to type 'string'

// Intersection — the conflict silently produces never
type A2 = { value: string };
type B2 = { value: number };
type C  = A2 & B2;   // { value: never }

declare const c: C;
c.value;   // type is never — every assignment fails at the use-site, not here
Warning
The intersection-producing-never issue is a common source of hard-to-diagnose type errors. If a property type becomes never, assignments to it fail with confusing messages far from the definition. Prefer extends to surface conflicts early.
Conflict Resolution in Extended Interfaces

When extending interfaces TypeScript enforces compatibility: the child's property type must be assignable to the parent's. You can narrow a type (more specific) but you cannot widen or change it entirely.

TS
interface Shape {
  color: string;
  area(): number;
}

// Narrowing a property — allowed (literal 'red' is assignable to string)
interface RedShape extends Shape {
  color: 'red';
}

// Changing to an incompatible type — compile error
interface BadShape extends Shape {
  color: number;   // Error: type 'number' is not assignable to type 'string'
}

// Narrowing a return type — also allowed (covariance)
interface PreciseSquare extends Shape {
  area(): 100;   // literal 100 is assignable to number
}

Resolving conflicts across multiple parents

When two parent interfaces define the same property with incompatible types, extending both is a compile error. You must explicitly resolve the conflict in the child:

TS
interface WithStringId {
  id: string;
}

interface WithNumberId {
  id: number;
}

// Direct extension fails — string and number are incompatible
// interface Conflict extends WithStringId, WithNumberId {}   // Error

// Best solution: redesign or use a common supertype
interface NormalizedEntity {
  id: string | number;
  name: string;
}

// Or pick one and be explicit
interface StringEntity extends WithStringId {
  name: string;
}
Declaration Merging Gotchas

Declaration merging is powerful but has several traps to watch out for.

1. Order of method overloads

Later declarations produce overloads that are checked before earlier ones. This can cause surprising resolution if you rely on a specific order:

TS
// Declared first
interface Parser {
  parse(input: string): number;
}

// Declared second (later)
interface Parser {
  parse(input: string): boolean;
}

// Merged order (later declaration checked first):
// parse(input: string): boolean   <- wins
// parse(input: string): number

declare const p: Parser;
const result = p.parse('42');  // inferred as boolean, not number
Warning
Because TypeScript evaluates overloads top-to-bottom and returns the first match, merging can silently change which overload wins. Keep related overloads in a single interface declaration when order is important.

2. Merging is scoped to the same module

Two interface Foo declarations in different modules do not automatically merge — they are distinct types. Merging across module boundaries requires an explicit declare module augmentation.

TS
// module-a.ts
export interface Config {
  debug: boolean;
}

// module-b.ts — this is a completely separate type, NOT merged
export interface Config {
  verbose: boolean;
}

// To truly augment from outside the module, use augmentation syntax:
// augment-b.ts
declare module './module-a' {
  interface Config {
    verbose: boolean;   // now properly merged into module-a's Config
  }
}

3. You cannot merge type aliases

Re-declaring a type alias is always a compile error:

TS
type Point = { x: number; y: number };
type Point = { z: number };   // Error: Duplicate identifier 'Point'

// Use interface if you need mergeability:
interface Point { x: number; y: number }
interface Point { z: number }   // OK — merged: { x: number; y: number; z: number }
Practical Pattern: Progressive Interface Layers

A clean architectural pattern for large applications is to build interface hierarchies in layers, from the most generic to the most specific. Each layer adds only what it owns:

TS
// Layer 1 — base entity (shared by every domain object)
interface BaseEntity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}

// Layer 2 — soft-deletable entity
interface SoftDeletable extends BaseEntity {
  deletedAt: Date | null;
  isDeleted: boolean;
}

// Layer 3 — auditable entity (tracks who made changes)
interface Auditable extends SoftDeletable {
  createdBy: string;
  updatedBy: string;
}

// Layer 4 — concrete domain object
interface Article extends Auditable {
  title: string;
  slug: string;
  body: string;
  publishedAt: Date | null;
  tags: string[];
}

// A utility function that works on any SoftDeletable — including Article
function restore(entity: SoftDeletable): void {
  entity.deletedAt = null;
  entity.isDeleted = false;
}

declare const article: Article;
restore(article);   // works because Article extends SoftDeletable
Success
This layered approach means changes to base contracts automatically propagate to all descendant types — no search-and-replace required.
Practical Pattern: Plugin System with Augmentation

Declaration merging is ideal for plugin systems where each plugin needs to contribute its own data to a shared context object without modifying the core package:

TS
// core/types.ts — the shared context interface
export interface AppContext {
  userId: string;
  locale: string;
}

// plugins/analytics/types.ts — augment with analytics data
declare module 'core/types' {
  interface AppContext {
    analyticsSessionId: string;
    pageViewCount: number;
  }
}

// plugins/feature-flags/types.ts — augment with feature-flag data
declare module 'core/types' {
  interface AppContext {
    flags: Record<string, boolean>;
  }
}

// Any file that imports AppContext sees all merged members:
import { AppContext } from 'core/types';

function handleRequest(ctx: AppContext) {
  console.log(ctx.userId);              // from core
  console.log(ctx.analyticsSessionId); // from analytics plugin
  console.log(ctx.flags['new-ui']);     // from feature-flags plugin
}
Compile-Time Subtype Assertions

You can write zero-cost compile-time tests that verify your hierarchy is correct. If the assertion fails, the build breaks — no runtime test runner needed:

TS
// A utility that turns true/false into a type-level assertion
type Assert<T extends true> = T;
type IsAssignable<Child, Parent> = Child extends Parent ? true : false;

// Verify our layered hierarchy is sound
type _checks = [
  Assert<IsAssignable<Article, Auditable>>,     // Article extends Auditable
  Assert<IsAssignable<Article, SoftDeletable>>, // Article extends SoftDeletable
  Assert<IsAssignable<Article, BaseEntity>>,    // Article extends BaseEntity
];
// If any assertion is false, TypeScript reports: Type 'false' does not satisfy
// the constraint 'true'
Best Practices for Large Type Hierarchies
  1. Prefer interfaces over type aliases when you need extensibility — interfaces support merging and tend to produce clearer error messages in IDEs.

  2. Keep hierarchies shallow — more than 3-4 levels of inheritance becomes hard to reason about. Favour composition (having a property of a type) when depth creeps up.

  3. Name base interfaces clearly — prefix with Base or suffix with Like (e.g. BaseUser, SerializableLike) so readers immediately understand the role.

  4. Centralise augmentation declarations — put all module augmentations in a dedicated types/ or @types/ folder so they are easy to discover and do not surprise future readers.

  5. Avoid merging inside application logic — save declaration merging for library or plugin boundaries. Inside a single app, prefer explicit extension so the full shape is visible in one place.

  6. Document conflicting overloads — when merging produces method overloads, add a comment explaining the intended resolution order.

  7. Write compile-time assertions — use the IsAssignable pattern to guard your hierarchy against accidental regressions when base interfaces change.

Quick Reference: Choosing the Right Tool

Goal

Tool to use

Create a new type that inherits another

interface C extends A

Combine two unrelated interfaces into one

interface C extends A, B

Add fields to an interface you own

Second interface declaration (merging)

Add fields to a third-party interface

declare module + interface augmentation

Combine types including primitives or unions

type C = A & B

Express a type that can be either A or B

type C = A | B

Narrow a property type in a child interface

extends + re-declare with narrower type

Verify hierarchy correctness at compile time

IsAssignable utility type + Assert

Mastering interface extension and declaration merging gives you precise control over how your type contracts evolve. You can grow them incrementally, integrate seamlessly with third-party libraries, build plugin architectures, and catch hierarchy bugs at compile time — all with zero runtime overhead, because interfaces disappear entirely when TypeScript compiles to JavaScript.