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."
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.
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.
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}`);
},
};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.
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(),
};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.
// 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 -> stringModule 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:
// 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" errorsexport {} 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:
// 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 });
}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) |
// 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 herenever 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.
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:
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:
// 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 number2. 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.
// 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:
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:
// 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 SoftDeletablePractical 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:
// 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:
// 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
Prefer interfaces over type aliases when you need extensibility — interfaces support merging and tend to produce clearer error messages in IDEs.
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.
Name base interfaces clearly — prefix with Base or suffix with Like (e.g. BaseUser, SerializableLike) so readers immediately understand the role.
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.
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.
Document conflicting overloads — when merging produces method overloads, add a comment explaining the intended resolution order.
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.