Your organisation wants a single house rule for `interface` versus `type` across a large TypeScript codebase. What rule would you set, where must it allow exceptions, and how much is the consistency actually worth?
answer
- a default, not a mandate
- the language forces some cases
- exceptions must be cheap
- payoff concentrates at the package boundary
- mass migration buys uniformity only
basics
~20 sSet a default rather than a mandate: interface for declared object shapes, type alias for everything else, with language-forced exceptions for unions, tuples, primitives and derived types. Apply it to new code only, and reserve real review attention for published, extensible surfaces.
solid answer
~60 sI would write it as a default with named exceptions: use `interface` for an object shape you declare directly, especially an exported one or one a class implements; use `type` for everything else — unions, tuples, primitive aliases, and any type derived from another type — because there the language leaves no choice. Two things keep the rule honest. First, exceptions must be cheap: a rule that pushes an author toward the wrong construct to satisfy a linter is worse than no rule. Second, the payoff is modest for internal shapes, where the two forms behave almost identically, and concentrated at the package boundary, where the choice decides whether consumers can extend your type. So I would enforce the default on new and changed code, refuse a mass migration of existing declarations, and spend the review budget on the exported surface instead. A secondary benefit is readability: a directly declared interface gives errors and hovers a single name to print, while types assembled from other types are often printed expanded.
go deeper
Know that teams usually settle on a default for object shapes, and that unions, tuples and primitive aliases must use type no matter what the convention says.
Be able to state the default and its forced exceptions, and explain why the two forms are nearly interchangeable for a plain internal object shape.
Argue from consequence: where an all-interface mandate creates real compile errors, why an assertion is the wrong escape hatch, and why enforcement should target new code rather than a repository-wide rewrite.
Own the tradeoff between uniformity and churn, insist that exceptions stay one line so the rule never pushes an author toward the wrong construct, and direct the review budget to the exported surface where the choice actually commits you.
## Start by asking what the rule is for A house rule about this pair buys three different things, and they are worth very different amounts: 1. **Removing a recurring bikeshed.** Real value, but small and one-off. 2. **Making the codebase legible** — a reader who sees `interface` knows it is a directly declared object shape, and a reader who sees `type` knows the type came from somewhere else. Real value, and it compounds. 3. **Getting the extensibility of the public API right.** This is where the choice actually carries a commitment, and it is the only part of the rule with a failure mode measured in broken consumers. A rule written as if all three matter equally produces mass migrations that deliver only the first. ## The rule I would write > **Default to `interface` for an object shape you declare directly. Use `type` when the type is not a directly declared object shape.** The second clause is not a preference — it is the language: unions, tuples, primitive and literal aliases, and anything computed from another type have no interface form. Two strengthenings are worth spelling out: - **Exported shapes that outside code should extend must be interfaces**, and that intent should be documented, because openness is a promise you cannot cheaply withdraw. - **Closed models are aliases on purpose.** A variant set you switch over exhaustively is a union, and its closedness is the feature — it is what makes exhaustive handling meaningful rather than aspirational. ## Where the rule must bend An always-interface mandate collides with reality in at least these places: - **Assignability to loose bag types.** An object-literal alias carries an implicit index signature that an interface does not, so an all-interface codebase hits index-signature errors at serialisation and integration boundaries. The right answer there is sometimes the alias, and a rule that forbids it pushes people toward assertions, which is strictly worse. - **Derived types.** Anything built by transforming another type must be an alias. - **Generated code.** Codegen tools have their own conventions and are not worth fighting. The general principle: any rule about which construct to use must lose to a rule about which construct is *correct*, and the exception must be one line, not an escalation. ## The readability argument, stated precisely A directly declared interface is a single named object type, so a hover or an error message has one name to print and one declaration to jump to. A type assembled by combining or transforming other types has no single declaration behind it, and tooling frequently prints it expanded — which is exactly when a diagnostic becomes a wall of members. That is a genuine argument for preferring a named, directly declared shape where you have the choice, and it is a better argument than "the style guide says so". What it is not is an argument for rewriting existing declarations: a working alias for a plain object shape prints perfectly well. ## Enforcement and migration - **Automate the default, keep the override trivial.** Automated enforcement is what makes a default stick, but only if an author can opt out inline with a short justification. Otherwise the rule quietly teaches people to work around the checker. - **New and changed code only.** A repository-wide rewrite of declarations produces an enormous diff, invalidates blame, risks changing assignability at the boundaries described above, and delivers nothing but uniformity. - **Spend the review attention at the boundary.** Reviewing whether an internal shape is `interface` or `type` is near-zero-value. Reviewing whether a newly exported type should be open or closed is high-value, because that decision ships. ## The honest caveat to state out loud For a plain object shape used inside one codebase, the two forms are close to interchangeable: same structural checking, same erasure, nothing emitted either way. Saying that plainly is a mark of judgment, because it locates the disagreement correctly — the choice is not about correctness in most of the code, and it is entirely about commitment in the small part of the code that other people import.
- Someone proposes a codemod converting every object-shape type alias in the repository to an interface. What do you say?I would decline. It touches thousands of declarations for uniformity alone, and it is not behaviour-neutral: object-literal aliases carry an implicit index signature that interfaces do not, so the change can break assignability at serialisation and integration boundaries. Apply the default going forward and leave working declarations alone.
- How would you decide whether a newly exported type should be open or closed?Ask whether extension is a supported use case. Options bags, plugin registries and context objects should be interfaces and documented as extensible. Variant sets and anything your own code enumerates exhaustively should be closed aliases, because once consumers can add members, your derived key sets and exhaustive handling stop being trustworthy.
- Does this choice have any effect worth measuring on build or runtime performance?Not at runtime — both forms are erased and emit nothing. Check-time effects exist in principle for heavily combined and transformed types, but they are dwarfed by other factors in a large project. Do not sell the rule on performance; sell it on legibility and on getting the public surface's extensibility right.
saying these in an interview costs you the question
- Mandates one form with no exceptions
- Proposes a repository-wide codemod as the first step
- Argues the choice on runtime performance grounds
- Treats it as purely cosmetic, including at the API boundary
- Cannot name a case where the language removes the choice