Lesson 1 of 8
Interfaces vs Type Aliases
"Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious." — Fred Brooks, The Mythical Man-Month
Part I taught you to speak TypeScript: annotations, unions, narrowing, the shapes of objects and functions. Part II is where the language earns its keep. Real systems don't traffic in lone strings — they move structured data: a user with a nested address, a paginated API response, a discriminated event, a config bag whose keys you can't fully predict. Modelling that data precisely, so the compiler catches a mismatch before a customer does, is the craft this Part is about. And it opens on the decision you'll make more than any other while doing it: given a shape to name, do you reach for interface or type? Part I introduced both and told you not to agonise. That was the right advice then. Now we go deep — because at the edges, the choice stops being cosmetic and starts having consequences.
For a plain object, interface Foo and type Foo are interchangeable — so what can each do that the other can't, and when does picking the wrong one actually bite you?
Core Concepts
Start from the truth Part I left you with: for a plain object shape, they are the same tool. interface User { name: string } and type User = { name: string } produce a type you use identically — to annotate variables, parameters, and returns — and TypeScript's structural checker treats values against either one the same way. If every type you ever wrote were a plain object, this chapter wouldn't exist. The differences live entirely at the edges, and there are exactly three that matter: what only type can express, what only interface can do, and how each one composes.
What only type can do
A type alias names any type. An interface names an object shape — and only an object shape. So the moment you need to name something that isn't an object, type is not a preference, it's the only tool that compiles:
Unions, tuples, primitives, function types, and every computed type — the result of a mapped type, a conditional type, or a utility like Partial<T> or Record<K, V> — can only be given a name by type. This is the single most decisive fact in the whole debate: a huge fraction of modern TypeScript is unions and utility results, and none of it can be an interface.
What's special about interface: declaration merging
Interfaces have one power aliases will never have. Two interface declarations with the same name in the same scope don't collide — they merge. TypeScript combines their members into a single interface, as if you'd written them together:
Try that with a type and the compiler stops you cold, on purpose:
Merging isn't a party trick — it's the mechanism behind augmentation. When you add a property to Window, teach TypeScript about a plugin that bolts fields onto Express's Request, or extend a third-party library's options without forking its .d.ts, you're relying on interface merging. Global and module augmentation are built on it. A type alias, being a single sealed name, simply can't participate — which is exactly why library authors expose their extensible surfaces as interfaces.
A type alias is a printed nameplate: coin it once, and a second nameplate with the same name is a conflict — the office won't allow two. An interface is a shared whiteboard headed with a role: you write "must have a title" today, a teammate adds "must have lessons" tomorrow, and the role is simply the sum of everything on the board. That additive, open-ended quality is declaration merging — wonderful when you want third parties to extend your shape, and a footgun when a duplicate name merges silently and you meant to redefine.
Extension: extends vs &
Both keywords compose shapes; they just spell it differently. An interface extends one or more others to inherit their members. A type uses the intersection operator &, which fuses shapes into one that has everything from both. And the two families interoperate freely — an interface can extend a type alias (as long as that alias is an object type), and an intersection can pull in an interface:
There's a real behavioural difference worth knowing. With extends, TypeScript checks the child against the parent at the point of declaration — if you extend a shape and redeclare a property with an incompatible type, you get an error right there. Intersection is lazier: & never errors on the combination itself; a conflict (say id: number intersected with id: string) quietly produces never for that property, and you only discover it when you try to assign a value. extends fails loud and early; & fails quiet and late.
Performance and error messages — the honest, small print
You'll read claims that interfaces are "faster." The grain of truth: because an interface has a stable, named identity, the compiler can cache its shape and, on a mismatch, report the error by name ("Property 'x' is missing in type 'User'") rather than expanding a giant anonymous intersection into your terminal. For big, deeply-composed object types this can mean marginally faster checks and distinctly more readable errors. But be honest about scale: on any normal codebase the difference is imperceptible. Choose for expressiveness and merging semantics first; treat the performance angle as a gentle tiebreaker for large public object shapes, not a rule.
The rule that fits on a card, and holds up in code review:
• Reach for interface for public object shapes, anything a class will implements, and any type you might need to merge or augment later.
• Reach for type for unions, tuples, function types, and anything computed by a mapped, conditional, or utility type — these have no interface form.
• Silent merging is the trap. Two same-named interfaces don't warn; if you meant to replace a shape and it merged instead, you'll chase a ghost. Aliases erroring on duplicates is a feature here.
• Consistency beats correctness-of-choice. A codebase where every non-object type is a type and every entity is an interface reads itself. Pick the convention, lint it, stop debating.
Declaration merging predates most of TypeScript's type system. It exists because the language had to describe JavaScript that was already written — libraries like jQuery where a function is also a namespace, or where plugins graft methods onto an existing object at runtime. Merging let a .d.ts model "this got extended over there" without owning the original source. Type aliases, arriving into a language that already leaned on merging for its ambient declarations, were deliberately kept sealed: a single, predictable identity was the whole point of an alias. So the split you feel today — open interfaces, closed aliases — isn't an accident of taste. It's two different jobs the language needed to do, frozen into two keywords.
The Academy is a Next.js + Drizzle/MySQL + TypeScript app, and the split falls out naturally. Domain entities that flow from the database through server actions into React props — Lesson, Certificate, Enrollment — are interfaces: they're the app's public vocabulary, some are implemented by service classes, and one lives in a global.d.ts that merges a user field onto the request context so every route handler sees it typed. Everything computed or non-object is a type: type LessonStatus = "draft" | "published" | "archived", type ActionResult<T> = { ok: true; data: T } | { ok: false; error: string }, and the Partial<Lesson> patch a draft-autosave endpoint accepts. Nobody debates it per file, because a lint rule already decided.
Common Mistakes
- Trying to alias a union with an interface. There is no
interface Statusfor"on" | "off". Unions, tuples, and function types aretype-only — this alone settles most real choices. - Redeclaring a
typeand expecting a redefinition. A secondtype Foodoesn't overwrite the first; it's aTS2300duplicate-identifier error. Only interfaces merge. - Being surprised by interface merging. The flip side of the above: two same-named interfaces merge silently. If a shape mysteriously requires a field you didn't put there, look for a second declaration.
- Assuming
A & Bvalidates the combination. Intersecting conflicting property types yieldsnever, not an error at the declaration — the failure surfaces later, at assignment, and reads confusingly.extendswould have caught it up front. - Choosing on rumoured performance. The compiler-speed gap between the two is negligible in normal code. Decide on expressiveness and merging; let performance break ties only for large public shapes.
- Mixing both keywords for the same kind of thing. Half your entities as
interface, half astype, for no reason, makes the codebase harder to read than either choice consistently applied.
You import a third-party library whose main config type is exported as type Options = { … }. Your plugin needs to add one field, telemetry: boolean, so consumers see it as part of Options. Can you? No. A sealed alias can't be reopened — your only moves are to fork the types or wrap them in a new name. Now imagine the author had written interface Options instead: you'd add interface Options { telemetry: boolean } in your own module augmentation and be done, no fork required. Same shape, same fields — but one keyword decided whether the outside world is allowed to extend it. That is why "interface vs type" is, for a library author, an API decision, not a style one.
Interview Corner
type can do that interface can't, and one thing interface can do that type can't."type can alias a union like string | number, plus tuples and function types — an interface only names object shapes. An interface can be declared twice and merge into one shape; redeclaring a type is a duplicate-identifier error."{ name: string }, which is right?" → Either — they're interchangeable; pick your team's convention.Window, or to Express's Request via module augmentation — without forking the library's declarations. I want to avoid it when I mean to redefine a shape: it merges silently instead of warning, so a stray duplicate can add a field I never intended. A type erroring on duplicates is safer there."extends differ from &?" → extends checks the child against the parent at declaration and errors on conflicts; & is lazy — a conflicting property becomes never and only bites at assignment.interfaces, because consumers can implements them and can augment them via declaration merging without forking, which keeps our surface open for a plugin ecosystem. Unions, discriminated result types, function signatures, and anything computed with mapped or utility types must be aliases — that's not a choice, it's the only tool that expresses them. The meta-decision is that an exported type is an API contract: whether outsiders can merge into it is a compatibility promise I make deliberately and lint into place, not something each author picks per file. Changing an exported shape is a breaking change, so I decide the extensibility story up front."implements, breaking changes — rather than a formatting preference.interface or type as you type; the spoken justification is the thing being graded.type twice?" versus the same interface, and a push to explain merging exactly.implements, and when aliases became powerful enough to own most non-object types.- In the editor above, declare a second
interface Coursethat adds anauthor: stringmember, then observe the existingts101object now fails to compile until you addauthor— proof that the two declarations merged into one shape. - Take the
type Configblock and try to "fix" the duplicate by merging the two bodies into a singletype Config = { debug: boolean; verbose: boolean }. Confirm theTS2300error disappears, and note that with an interface you'd never have had to. - Write
type ApiResult = { ok: true; data: string } | { ok: false; error: string }, then attempt to rewrite it as aninterfaceand watch it be impossible. Explain why in one line. - Say it out loud (as in a micro1 screen): in one sentence, give the rule you'd apply on the spot to choose between them, and name the single case where the choice genuinely changes behaviour.
Chapter Summary
- For a plain object shape,
interfaceandtypeare interchangeable; every real difference lives at the edges. - Only
typecan name non-object types: unions, tuples, primitives, function types, and any mapped/conditional/utility result. - Only
interfacesupports declaration merging — two same-named interfaces combine — which powers global and module augmentation of types you don't own. Redeclaring atypeis aTS2300duplicate-identifier error. - Compose with
interface B extends Aortype C = A & B; the families interoperate, butextendschecks conflicts at declaration while&producesneverand fails later. - Interfaces can be marginally faster to check and give name-based error messages for large object shapes — a real but small effect, a tiebreaker at most.
- The practical rule:
interfacefor public object shapes,implementstargets, and mergeable types;typefor unions, tuples, functions, and computed types. Consistency across the codebase matters more than any single choice.
Twice now the intersection operator has slipped in as "the way a type composes shapes" — User & { role: "admin" }, Person & { subject: string } — and each time we've read it loosely as "and also has." It deserves better than a footnote. What does & really mean when the operands overlap, or conflict, or aren't objects at all? Why does intersecting number with string yield never, and how do you use that fact deliberately? That's the next chapter: Intersection Types.
Editorial reflection — the honest shape of this debate is that it's small at the centre and sharp at the edges. Ninety-five percent of the time you're naming a plain object and it truly doesn't matter; spend zero energy there. The five percent — a union that can't be an interface, a library shape that must stay mergeable, an exported contract dozens of teams build on — is where the keyword becomes a design decision with a blast radius. The skill isn't memorising a rule; it's recognising which case you're in. Reserve your judgement for the edges, automate the centre with a lint rule, and the question that eats so many code reviews quietly answers itself.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.