Interfaces vs Type Aliases
The Question Every TypeScript Developer Asks
"Should I use interface or type?" This is the most asked TypeScript question on StackOverflow, and most answers get it wrong by saying "they're basically the same." They're not. They have different capabilities, different performance characteristics, and different use cases.
Think of interface as a contract that can be extended — like an open standard that others can add to. Think of type as a computed definition — like a mathematical formula that produces a fixed result. Interfaces are for shapes you want others to build on. Types are for computed, combined, or non-object types that don't need extension.
Syntax Comparison
Both can describe object shapes:
// Interface
interface User {
name: string;
age: number;
}
// Type alias
type User = {
name: string;
age: number;
};
For simple objects, they're interchangeable. The differences emerge with advanced features.
What Only type Can Do
Type aliases can represent things interfaces cannot:
// Union types — interface CANNOT do this
type Status = "idle" | "loading" | "success" | "error";
type StringOrNumber = string | number;
// Intersection types
type AdminUser = User & { permissions: string[] };
// Tuple types
type Pair = [string, number];
// Mapped types
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
// Computed property types from unions
type EventMap = {
[K in "click" | "hover" | "focus"]: (event: Event) => void;
};
// Template literal types
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
What Only interface Can Do
Interfaces have two unique capabilities:
1. Declaration Merging
Multiple interface declarations with the same name in the same scope merge automatically:
interface Window {
title: string;
}
interface Window {
appVersion: number;
}
// Merged result:
// interface Window {
// title: string;
// appVersion: number;
// }
const w: Window = { title: "App", appVersion: 1 }; // OK
This is impossible with type — duplicate type names cause an error:
type Window = { title: string };
type Window = { appVersion: number }; // ERROR: Duplicate identifier 'Window'
Why declaration merging exists and when it matters
Declaration merging was designed for extending third-party types without modifying their source code. It's critical for:
- Global augmentation — extending
Window,Document,HTMLElementwith custom properties - Module augmentation — adding types to libraries like Express, React, or Next.js
- Ambient declarations —
.d.tsfiles that describe JavaScript libraries without source
// Extending Express Request
declare module "express" {
interface Request {
user?: { id: string; role: string };
}
}
// Now req.user is typed everywhere
app.get("/profile", (req, res) => {
console.log(req.user?.id); // OK — merged into Request
});If Request were a type alias, this augmentation would be impossible.
2. extends Keyword
Interfaces use extends for inheritance, which provides clearer error messages than intersections:
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Equivalent with types — but different error behavior
type Animal = { name: string };
type Dog = Animal & { breed: string };
The functional result is similar, but extends checks for conflicts immediately:
interface Base {
id: number;
}
interface Child extends Base {
id: string; // ERROR: Type 'string' is not assignable to type 'number'
}
// With intersection — no immediate error, creates 'never'
type Base = { id: number };
type Child = Base & { id: string }; // id becomes 'never' (number & string)
// You only discover this when you try to use id
Intersecting conflicting properties creates never types silently. The error only surfaces when you try to use the conflicting property, which might be far from where the intersection was defined. extends catches conflicts at the declaration site. This is why extends is safer for object inheritance — it fails fast and fails clearly.
Performance Differences
TypeScript's compiler treats interfaces and types differently internally:
// Interface — cached by name, checked lazily
interface Props {
name: string;
onClick: () => void;
}
// Type intersection — computed eagerly every time it's referenced
type Props = BaseProps & ClickableProps & StyleProps;
Interfaces are named types that the compiler can cache and reference by identity. Complex type intersections are anonymous types that must be computed (flattened) each time they're used. In large codebases with many intersections, this can measurably slow compilation.
For object shapes, prefer interface. For unions, tuples, mapped types, or computed types, use type. When extending object shapes, prefer interface extends over type & intersections — it's both faster to compile and gives better error messages.
The Decision Framework
// USE INTERFACE when:
// 1. Defining object shapes (especially public APIs)
interface UserService {
getUser(id: string): Promise<User>;
updateUser(id: string, data: Partial<User>): Promise<User>;
}
// 2. You want declaration merging (library authors)
interface AppConfig {
apiUrl: string;
}
// 3. Class implementations
interface Serializable {
serialize(): string;
}
class UserModel implements Serializable {
serialize() { return JSON.stringify(this); }
}
// USE TYPE when:
// 1. Union types (interface can't do this)
type Result<T> = { ok: true; data: T } | { ok: false; error: Error };
// 2. Tuple types
type Coordinate = [number, number];
// 3. Mapped/conditional/template literal types
type Nullable<T> = { [K in keyof T]: T[K] | null };
// 4. Extracting types from values
const config = { host: "localhost", port: 3000 } as const;
type Config = typeof config;
// 5. Function types (cleaner syntax)
type Handler = (event: Event) => void;
Production Scenario: Component Props
// Base props interface — other components extend this
interface BaseProps {
className?: string;
testId?: string;
}
// Button extends base — clear inheritance chain
interface ButtonProps extends BaseProps {
variant: "primary" | "secondary" | "ghost";
size: "sm" | "md" | "lg";
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}
// Union type for button state — interface can't express this
type ButtonState =
| { status: "idle" }
| { status: "loading"; progress?: number }
| { status: "success"; message: string }
| { status: "error"; error: Error; retry: () => void };
// Combining both — interface for shape, type for unions
interface StatefulButtonProps extends ButtonProps {
state: ButtonState;
}
-
Wrong: Using type for all object shapes because 'type can do everything' Right: Use interface for object shapes — better errors, performance, and extensibility
-
Wrong: Using type intersections for inheritance:
type Child = Parent & { extra: string }Right: Use interface extends:interface Child extends Parent { extra: string } -
Wrong: Using interface for union types by creating a shared base Right: Use type for unions directly: type Result = Success | Failure
-
Wrong: Mixing interface and type randomly without a consistent rule Right: Pick a convention: interface for extendable shapes, type for everything else
Migrating Intersections to Interfaces
- 1Use interface for object shapes — it's cached by the compiler, supports extends, and enables declaration merging
- 2Use type for unions, tuples, mapped types, conditional types, and computed types — interface can't express these
- 3Prefer extends over & for object inheritance — extends catches conflicts immediately, intersections create silent never
- 4Declaration merging (interface only) is essential for augmenting third-party types and global types
- 5In large codebases, complex type intersections can measurably slow compilation — prefer interface extends