interface vs type: when each one earns it
TypeScript gives you two ways to declare an object shape, and most
projects pick one by accident. interface User { name: string }
and type User = { name: string } look like the same
statement spelled twice. In nine out of ten places in your code they
are. The tenth is where the decision starts to compound, and the
codebase that mixed them at random pays interest forever.
Start with what they share. For a plain object shape, the two forms produce identical types from the type checker’s point of view:
interface User {
id: string;
name: string;
}
type User = {
id: string;
name: string;
}; Same checks pass, same errors fire, same auto-completion in the editor. If your codebase only ever describes object shapes, the answer is “pick one and stop thinking about it.” The reason this essay exists is that real codebases do not stop at object shapes.
The boring answer is the one that scales: default to
interface, switch to type when the language
forces your hand, and never mix the two by accident.
What only type can do
A type alias names any type expression. An
interface only names an object type. The asymmetry
matters as soon as you reach for a feature that is not an object.
// Unions — required for tagged variants and nullable shapes
type ID = string | number;
type Maybe<T> = T | null;
// Tuples — fixed-shape arrays interfaces cannot describe
type Pair<T> = [T, T];
// Primitive aliases — the type checker treats them as distinct names
type Email = string;
type UserId = string & { __brand: 'UserId' };
// Mapped types — transform every key of another type
type Optional<T> = { [K in keyof T]?: T[K] };
// Conditional types — branch on whether one type extends another
type Unwrap<T> = T extends Promise<infer U> ? U : T;
// Template literal types — string types built from other strings
type EventName = `on${Capitalize<string>`;
None of those have an interface equivalent. They are not
interface limitations — they are language features that only
type exposes. If you need any of them, the choice is
made for you.
The literal-type narrowing patterns from
when string is a lie are a good
example: every discriminated union you write is a type,
not an interface, because the union itself cannot be an
interface.
What only interface can do
The other direction is shorter but more important than it looks.
Interfaces support declaration merging: two
interface declarations with the same name in the same
scope combine their members into one type. Type aliases do not. The
second declaration is an error.
interface User { id: string; }
interface User { name: string; }
// User is now { id: string; name: string }
type Admin = { id: string };
type Admin = { name: string }; // Error: duplicate identifier
On its own this looks like a footgun — quietly merging types is
rarely what you want inside one file. The reason it exists is module
augmentation. When you need to extend a type from a third-party
library — add a property to Window, register a
custom theme on the Express Request, attach a field to
process.env — the only mechanism is to re-open
that library’s interface from inside your code.
// global.d.ts
declare global {
interface Window {
analytics?: { track: (e: string) => void };
}
}
// somewhere in app code
window.analytics?.track('page_view'); // typed, no any
If Window were a type alias upstream, you
could not augment it. You would write a wrapper, or fall back to
any. This is the single biggest reason the TypeScript
handbook recommends interfaces for public types your library exports
— the consumers might need to extend them.
The performance footnote
The TypeScript team itself recommends preferring interfaces for
object shapes, citing compiler performance. The mechanism: when you
write type Big = A & B & C, the compiler computes
the intersection on demand at every reference site. When you write
interface Big extends A, B, C {}, the resolved
member list is cached on the interface itself.
For a typical application this is invisible. The place it shows up is
in projects that consume large generated type packages —
something like @types/stripe, an OpenAPI-generated
client, or a Prisma schema with hundreds of models. Compile times
that grew from 3 seconds to 30 are usually a story about
intersections inside intersections, not about lines of code.
The rule of thumb: if the type names a thing your code talks about,
use interface. The cache wins are real and the cost is
zero.
How errors read
A small thing that compounds. When a type-check fails on an interface, the error message tends to keep the interface name. When it fails on an intersection, the message can expand the intersection inline:
// interface — readable
// "Property 'email' is missing in type 'Partial<User>' but required in type 'User'"
// intersection — sometimes legible, sometimes a wall of types
// "Type ... is not assignable to type '{ id: string } & { name: string } & { email: string }'" Modern TypeScript versions are much better at preserving names in intersection errors than they were three years ago, so this is a minor tiebreaker now rather than a deciding factor. But it is still a tiebreaker in the same direction.
The rule that scales
- Default to
interfacewhen the type is a named object shape. - Use
typewhen you need a union, tuple, mapped, conditional, or template literal type, or when you are aliasing a primitive with a brand. - Use
typefor shapes that compose with&from other types — the alias gives you a name to refer to. - Pick once and lint it.
@typescript-eslint/consistent-type-definitionscan enforce the rule. The cost of revisiting the question every time you write a type is more than the cost of any rule.
The short version: both work. The codebases that picked the boring rule and stuck with it are the ones whose types still compile in under five seconds five years in. That is the answer that earns its keep.