Shaping Data with Types

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.

The question this chapter answers

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:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

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:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

Try that with a type and the compiler stops you cold, on purpose:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

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.

Everyday analogy

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:

TypeScript — Try it Yourself
Loading editor…
Type check + Console

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 engineer's notebook

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.

History changed here

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.

Real-world example — chika.ng Academy

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 Status for "on" | "off". Unions, tuples, and function types are type-only — this alone settles most real choices.
  • Redeclaring a type and expecting a redefinition. A second type Foo doesn't overwrite the first; it's a TS2300 duplicate-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 & B validates the combination. Intersecting conflicting property types yields never, not an error at the declaration — the failure surfaces later, at assignment, and reads confusingly. extends would 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 as type, for no reason, makes the codebase harder to read than either choice consistently applied.
Thought experiment

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

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
"Name one thing type can do that interface can't, and one thing interface can do that type can't."
Model answer — "A 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."
Follow-up they'll throw: "So for a plain { name: string }, which is right?" → Either — they're interchangeable; pick your team's convention.
Intermediate
"What is declaration merging, and give a real reason you'd want it — or want to avoid it."
Model answer — "Two same-named interfaces combine their members into one. I want it to augment types I don't own — adding a field to 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."
Follow-up: "How does 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.
Senior
"You own the public types for a package dozens of teams import. How does the interface-vs-type choice shape that API?"
Model answer — "I expose extensible object shapes — entities, options bags, request/context types — as 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."
The senior signal is framing the keyword as an API and compatibility decision — merging, implements, breaking changes — rather than a formatting preference.
Company lens — how the same topic gets asked
micro1Live and conversational — reason out loud. Asked to model a shape, narrate why you pick interface or type as you type; the spoken justification is the thing being graded.
GooglePrecision on the edges — expect "what happens if you declare the same type twice?" versus the same interface, and a push to explain merging exactly.
MicrosoftThey authored TypeScript — expect real depth on why both exist: declaration merging for augmenting ambient/library types, implements, and when aliases became powerful enough to own most non-object types.
Amazon / AWSOperational lens: "a shared exported type changed and broke three services — how do you evolve public shapes safely?" Tie interfaces, merging, and breaking-change discipline to reliability.
Hands-on exercise
  1. In the editor above, declare a second interface Course that adds an author: string member, then observe the existing ts101 object now fails to compile until you add author — proof that the two declarations merged into one shape.
  2. Take the type Config block and try to "fix" the duplicate by merging the two bodies into a single type Config = { debug: boolean; verbose: boolean }. Confirm the TS2300 error disappears, and note that with an interface you'd never have had to.
  3. Write type ApiResult = { ok: true; data: string } | { ok: false; error: string }, then attempt to rewrite it as an interface and watch it be impossible. Explain why in one line.
  4. 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, interface and type are interchangeable; every real difference lives at the edges.
  • Only type can name non-object types: unions, tuples, primitives, function types, and any mapped/conditional/utility result.
  • Only interface supports declaration merging — two same-named interfaces combine — which powers global and module augmentation of types you don't own. Redeclaring a type is a TS2300 duplicate-identifier error.
  • Compose with interface B extends A or type C = A & B; the families interoperate, but extends checks conflicts at declaration while & produces never and 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: interface for public object shapes, implements targets, and mergeable types; type for unions, tuples, functions, and computed types. Consistency across the codebase matters more than any single choice.
Narrative bridge

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.