Interface vs Type Alias: A Decision Tree
The Core Differences at a Glance
Both interface and type can describe object shapes, but they work differently under the hood:
// Defined with interface
interface Point {
x: number;
y: number;
}
// Defined with type โ looks identical
type Point2 = {
x: number;
y: number;
};
const p1: Point = { x: 1, y: 2 };
const p2: Point2 = { x: 1, y: 2 };
Surface similarity aside, three key differences determine which to reach for: declaration merging, computational power, and the range of types each can express.
Difference 1: Declaration Merging
An interface can be declared multiple times; TypeScript automatically merges the declarations. type cannot โ a duplicate declaration is an error.
// interface merges automatically
interface Config {
host: string;
}
interface Config {
port: number;
}
// Config now has both host and port
const cfg: Config = { host: "localhost", port: 3000 };
// type does not support merging
type Config2 = { host: string; };
// type Config2 = { port: number; }; // Error: duplicate identifier
The practical use of declaration merging: augmenting third-party library types.
// Extend Express's Request type with a custom property
// This is idiomatic in Express applications
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string };
}
}
}
import { Request, Response } from "express";
function authMiddleware(req: Request, res: Response, next: () => void) {
req.user = { id: "123", role: "admin" }; // No error
next();
}
Difference 2: type Can Be Computed; interface Cannot
type can express unions, intersections, mapped types, and conditional types. interface is limited to fixed object shapes.
// type can be a union
type ID = string | number;
// type can be a mapped type (make all properties optional)
type Partial<T> = {
[K in keyof T]?: T[K];
};
// type can be a conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// type can alias primitives and function shapes
type UserId = string;
type Callback = (err: Error | null, result: string) => void;
// interface cannot do any of this:
// interface ID = string | number; // Syntax error
// interface Callback = (x: number) => void; // Must use object syntax
Difference 3: How Extension Works
interface uses extends with early, clear errors. type uses & intersection, but conflicts are silent at merge time and only surface at use sites.
// interface extends โ straightforward inheritance
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
const dog: Dog = { name: "Rex", breed: "Labrador" };
// type uses intersection
type AnimalType = { name: string };
type DogType = AnimalType & { breed: string };
const dog2: DogType = { name: "Rex", breed: "Labrador" };
Conflict behaviour:
interface A1 { value: string; }
interface B1 extends A1 { value: number; }
// Error: Interface 'B1' incorrectly extends interface 'A1'
// TypeScript reports this at the extends clause โ easy to find
type A2 = { value: string };
type B2 = A2 & { value: number };
// No error here โ but B2.value is string & number = never
// The error only appears when you try to assign to B2.value
readonly and Optional ? Properties
Both interface and type support these modifiers:
interface UserProfile {
readonly id: string; // Cannot be changed after creation
name: string;
email?: string; // Optional property
readonly createdAt: Date;
}
const profile: UserProfile = {
id: "u-001",
name: "Alice",
createdAt: new Date(),
};
profile.name = "Bob"; // OK
profile.id = "u-002"; // Error: Cannot assign to 'id' โ it is read-only
// Readonly arrays
interface Config {
readonly allowedOrigins: readonly string[];
}
const config: Config = {
allowedOrigins: ["https://example.com"],
};
config.allowedOrigins.push("https://evil.com"); // Error: push does not exist on readonly string[]
Index Signatures
Use an index signature when key names are unknown but the value type is fixed:
interface ScoreBoard {
[playerName: string]: number;
}
const scores: ScoreBoard = {
alice: 100,
bob: 85,
};
scores.charlie = 90; // OK
scores.dave = "high"; // Error: string is not assignable to number
Named properties must be compatible with the index signature's value type:
interface BadConfig {
[key: string]: number;
name: string; // Error: string is not compatible with number
count: number; // OK
}
// Fix: widen the index signature value type
interface GoodConfig {
[key: string]: number | string;
name: string; // OK
count: number; // OK
}
Prefer Record<string, T> over raw index signatures โ it's cleaner:
type ScoreBoard2 = Record<string, number>;
// Restrict the key set
type StatusMap = Record<"active" | "inactive" | "banned", number>;
const userCounts: StatusMap = {
active: 150,
inactive: 30,
banned: 5,
};
Classes and Interfaces
A class can implement one or more interfaces, which act as contracts:
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
interface Loggable {
log(message: string): void;
}
class DatabaseRecord implements Serializable, Loggable {
private data: Record<string, unknown> = {};
serialize(): string {
return JSON.stringify(this.data);
}
deserialize(json: string): void {
this.data = JSON.parse(json);
}
log(message: string): void {
console.log(`[DatabaseRecord] ${message}`);
}
}
type objects can also be used with implements, but interface communicates intent more clearly.
Decision Tree: Which One to Use?
What do you need?
โ
โโ Union type? (A | B) โ type
โโ Primitive alias? (type UserId = string) โ type
โโ Mapped type? (Partial<T>, Pick<T, K>) โ type
โโ Conditional type? (T extends U ? X : Y) โ type
โโ Shorthand function type? (type Fn = () => void) โ type
โ
โโ Contract for a class? (implements) โ interface
โโ Public API object shape? (may be extended) โ interface
โโ Need declaration merging? (augment 3rd-party) โ interface
โ
โโ Plain object shape, none of the above? โ follow team convention
Quick Comparison Table
| Scenario | interface |
type |
|---|---|---|
| Define object shape | โ | โ |
| Union types | โ | โ |
| Extension / merging | extends |
& |
| Declaration merging | โ | โ |
| Mapped / conditional types | โ | โ |
implements on class |
โ (preferred) | โ |
| Augment third-party types | โ (preferred) | Possible but awkward |
| Error message clarity | Higher | Lower for unions/intersections |
Anti-Patterns
// Anti-pattern: trying to use interface for union types โ impossible
// interface Status = "active" | "inactive"; // Syntax error
// Workaround that is ugly and imprecise:
interface Status {
value: "active" | "inactive";
}
// Correct:
type Status = "active" | "inactive";
// ----
// Anti-pattern: using type for public API shapes that consumers should extend
type PluginOptions = {
timeout: number;
};
// Consumers cannot use declaration merging with type.
// They must resort to intersection, which is worse DX.
// Correct: use interface for extensible public API shapes
interface PluginOptions {
timeout: number;
}
// Consumer can cleanly augment:
interface PluginOptions {
retryCount: number; // Declaration merging โ clean and idiomatic
}
Key Takeaways
| Key Point | Notes |
|---|---|
| Declaration merging | interface only โ use to augment third-party types |
| Computed types | type only โ unions, mapped types, conditional types |
extends vs & |
interface extends errors early; type & conflicts hide until use |
readonly |
Both support it โ prevent accidental mutation |
? optional |
Both support it โ distinct from T | undefined in strictness |
| Index signatures | Both support it โ prefer Record<string, T> |
| Public API types | Prefer interface for consumer extensibility |
| Internal types | Either works โ pick one and stay consistent |
Next chapter: function types in depth โ overloads, generic functions, callable objects, and the subtle difference between void and undefined.