"The internet treats this as a holy war. It's a stylistic choice with two narrow asymmetries."
What you've already seen
You've met both. You've seen they overlap heavily for object shapes. You've seen the two asymmetries:
- Only
type: unions, intersections of arbitrary types, mapped types, conditional types, template literal types, derived types viakeyof/typeof/indexed access. - Only
interface: declaration merging (including augmenting globals and library types).
For pure object shapes, they're functionally equivalent. The choice is style.
The cheat sheet that solves 95% of cases
| You want… | Use… |
|---|---|
| A union type | type |
| A function signature alone | type |
| A derived type (Partial, ReturnType, keyof, …) | type |
| A conditional or mapped type | type |
| A public API object shape | interface (style) |
| To extend a third-party type | interface (declaration merging) |
| A plain internal object shape | Either — pick one, be consistent |
Error messages used to be the tiebreaker — now they're one weak signal
This used to be the most concrete rule, and you'll still see it repeated everywhere. The old story: interfaces show up in errors by their declared name, type aliases show up expanded into their full shape. That was true years ago — but since TypeScript 4.2 the compiler preserves alias names in most everyday cases. Today a plain type UserT = { … } shows up as UserT in the error, exactly like an interface. Preservation is a heuristic — a heavily computed alias (intersections, mapped or conditional types) can still expand — but for the simple named object shapes this rule was ever about, both print their name. So error legibility isn't the tiebreaker anymore; it's a weak signal at most. If you default to interface for a public API, do it for declaration merging, augmentation intent, and named-contract readability — not because "the alias expands." These days it usually doesn't.
Performance — the boring real-world difference
For very large types, interface can be slightly faster to type-check than equivalent intersected type aliases. The reason: interface extension is a recognized pattern with a fast path; intersection is the general-purpose tool. In practice this matters for monorepos with hundreds of types, not for individual files. If your tsc --noEmit is suddenly slow, this is one thing to check.
Pippa's confession
interface for shapes that come from the API or are consumed across the codebase as named contracts. type for unions, derived types, function signatures, and one-off local shapes. Written down in CLAUDE.md so future-me doesn't drift. The thing that matters more than the choice is that the choice is consistent.
Hello, Pippa.
Thank you for the excellent write-up on type vs interface. It's so helpful.
I have a quick question regarding the "Error Legibility" section. You mentioned that type aliases expand in error messages, but I noticed that since TypeScript 4.2, the compiler now preserves the type name (e.g., showing UserT instead of expanding the shape).
Given this update, do you still feel interface holds a significant advantage for error readability, or has it mostly become a matter of style preference? I'd love to hear your thoughts.