03 · Interfaces vs Type Aliases Deep Dive¶
Level 1 gave you the quick
rule of thumb: interface for object shapes, type for everything else.
That's good enough for most days, but real codebases eventually hit the
cases where the choice actually matters — declaration merging, how each
one combines with others, and where one can express something the other
flatly cannot.
The 90% overlap¶
For a plain object shape, both forms produce equivalent, mutually assignable types:
interface UserI {
id: number;
name: string;
}
type UserT = {
id: number;
name: string;
};
function printUser(u: UserI): void {
console.log(`#${u.id} ${u.name}`);
}
const asType: UserT = { id: 1, name: "Amy" };
printUser(asType); // fine -- structural typing doesn't care which
// declaration form produced the shape
// #1 Amy
Both support optional (?) and readonly properties, function-typed
properties, and generics. The differences show up around the edges.
Difference 1: declaration merging¶
Interfaces with the same name in the same scope merge automatically; type aliases with the same name are a compile error:
interface Window {
title: string;
}
interface Window {
height: number;
}
// Merged automatically into: { title: string; height: number }
const w: Window = { title: "Main", height: 600 };
console.log(w.title, w.height); // Main 600
// type Config = { title: string };
// type Config = { height: number };
// error TS2300: Duplicate identifier 'Config'.
This isn't a party trick — it's how ambient type declarations extend
third-party libraries (e.g. adding custom properties to Express's
Request type) without editing the library's source. It's also a trap
when unintended: an interface named the same thing as one in a library
you imported can silently merge rather than error, quietly reshaping a
type you didn't mean to touch.
Difference 2: extends vs &¶
Interfaces extend other interfaces; type aliases combine with &. They
look similar but behave differently on conflicting members:
interface Base {
id: string;
}
interface Derived extends Base {
id: string; // fine -- same type as Base.id, this is allowed
extra: number;
}
// interface Conflicting extends Base {
// id: number; // error TS2430: Interface 'Conflicting' incorrectly
// // extends interface 'Base'. Types of property 'id'
// // are incompatible.
// }
An interface extends clause checks compatibility immediately and
errors right at the declaration if the shapes conflict. An intersection
type, on the other hand, just merges — and if the merge is impossible, it
resolves the conflicting property to never instead of raising an error
at the declaration site:
type BaseT = { id: string };
type ConflictingT = BaseT & { id: number };
// No error here! ConflictingT.id has type `string & number`, which is `never`.
// const bad: ConflictingT = { id: "x" };
// error TS2322: Type 'string' is not assignable to type 'never'.
// The error only surfaces later, when you actually try to use the type --
// which can make the root cause harder to track down.
This is a genuine trap: prefer interface extends over & when you
expect the shapes might conflict, specifically because it fails fast at
the point of the mistake rather than somewhere downstream.
Difference 3: only type aliases name non-object types¶
interface can only describe object-like shapes (including function and
constructor signatures). type can name anything:
type ID = string | number; // interface cannot do this
type Coordinates = [number, number]; // or this (tuple)
type Handler = (event: string) => void; // interfaces CAN do this one too,
// but the type-alias form reads
// more naturally for a bare function type
type Nullable<T> = T | null; // generic alias over a union
const id: ID = 42;
const point: Coordinates = [10, 20];
const onClick: Handler = (event) => console.log(`Clicked: ${event}`);
let maybe: Nullable<string> = null;
console.log(id, point, onClick.name === "" ? "(anonymous)" : onClick.name);
onClick("button"); // Clicked: button
maybe = "now set";
console.log(maybe); // now set
If what you're naming isn't fundamentally an object shape — a union, a
tuple, a mapped type, a conditional type — reach for type. There's no
equivalent interface syntax for a bare union or tuple.
Difference 4: interfaces support implicit merging with classes¶
A class can implement either form, but interfaces play a special role
when you want a type that's automatically satisfied by any object with
the right shape and documents an intended contract clearly:
interface Serializable {
serialize(): string;
}
class Invoice implements Serializable {
constructor(private amount: number) {}
serialize(): string {
return JSON.stringify({ amount: this.amount });
}
}
const inv = new Invoice(500);
console.log(inv.serialize()); // {"amount":500}
type works identically here too (type Serializable = { serialize():
string } and implements Serializable compiles the same way) — this is
really a style convention, not a functional difference. Public library
APIs and anything meant to be implements-ed by consumers lean toward
interface because merging lets consumers extend it later.
Practical rule of thumb¶
| Situation | Prefer |
|---|---|
| Plain object shape, especially a public API | interface |
| Might need to be extended by consumers later | interface |
| Union, tuple, primitive alias, or mapped/conditional type | type |
| You need declaration merging (extending a library's types) | interface |
| Combining several shapes where conflicts should fail loudly | interface extends |
| One-off, internal, "just needs a name for this specific shape" | either — pick a team convention and stay consistent |
How It Actually Works¶
The checker treats interface and object-shape type aliases as producing the same underlying structural type once resolved — assignability comparisons don't care which keyword declared a shape. The real differences are in how and when the compiler resolves each declaration. An interface is resolved lazily and incrementally: the checker can reference an interface's own members while still processing its declaration (self-reference), and declaration merging (covered in an earlier lesson) works because each interface X { } block is treated as a partial contribution to one accumulated member list, only flattened into a final shape when something actually needs to check against X.
A type alias for an object shape, by contrast, is resolved eagerly at the point of declaration into a fixed type — no merging, and a self-referencing type alias (type Tree = { children: Tree[] }) only works because object/array member positions are lazily evaluated internally by the checker, not because type aliases support recursion in general; a non-object self-referential alias (type Bad = Bad | string) is rejected as circular precisely because there's no member boundary to defer evaluation across.
This resolution difference is also why large unions of interface extends chains can be measurably slower to check than equivalent type intersections in some compiler versions — merged-and-then-flattened interface resolution vs. eagerly-computed intersection types exercise different code paths in the checker's caching, though this has narrowed across TypeScript releases and isn't a reliable rule to design around.
Neither form has any runtime representation — both compile to nothing, and both are checked through the same structural, member-by-member comparison; the choice between them is entirely about which authoring-time behaviors (mergeability, union/mapped-type operations only type supports) you need, not about differing runtime or type-safety guarantees.
Cheat sheet¶
| Feature | interface |
type |
|---|---|---|
| Object shapes | Yes | Yes |
| Unions / tuples / primitives | No | Yes |
| Declaration merging | Yes (same name merges) | No (duplicate name errors) |
| Combine with conflict detection | extends (errors early) |
& (resolves to never silently) |
| Generic | Yes | Yes |
implements in a class |
Yes | Yes |
Exercise¶
Declare an interface Vehicle with make: string and model: string.
Separately, in the same file, declare a second interface Vehicle that
adds year: number — confirm they merge into one three-property type.
Then write two type aliases, Success = { status: "success"; data: string
} and Failure = { status: "failure"; error: string }, and a union
Outcome = Success | Failure — this is something interface alone could
not express directly. Write a function report(outcome: Outcome): string
that narrows on status and returns the right message for each case.