skip to content

In TypeScript, when would you model a contract as an abstract class rather than as an interface, and what does each choice cost?

level: middleimportance: must knowfreq 61%

answer

  1. behaviour or just shape?
  2. one extends slot, many interfaces
  3. protected is inexpressible in an interface
  4. erased vs emitted as a value
  5. centralised code vs coupling

basics

~20 s

Reach for an abstract class when implementers should inherit real code — shared method bodies, constructor logic, protected hooks — alongside the mandatory members. Reach for an interface when you only need a shape: it is erased, costs nothing at runtime, and any class can satisfy it.

solid answer

~50 s

The deciding question is whether you are shipping **behaviour** or only a **shape**. An abstract class can carry implemented methods, fields, constructor logic, static members, and `protected abstract` hooks that only the hierarchy can see — an interface can carry none of those, since every interface member is public and body-less. In exchange, the abstract class is a real runtime value that consumes the implementer's single `extends` slot and couples them to your base's evolution, while an interface is erased entirely, imposes no inheritance relationship, and any number of them can be satisfied at once. My default is the interface for a boundary type — a port, a repository contract, a config shape — and the abstract class only when there is genuinely shared code I do not want copied into every implementer, typically a template-method algorithm with a couple of holes. The two also compose: an abstract class can implement a published interface, so consumers depend on the shape and implementers optionally inherit the convenience.

code

typescript · 27 lines
typescript
interface Notifier {
  send(message: string): Promise<void>;
}

abstract class RetryingNotifier implements Notifier {
  async send(message: string): Promise<void> {
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        await this.deliver(message);
        return;
      } catch {
        // fall through and retry
      }
    }
    throw new Error('delivery failed after 3 attempts');
  }
  protected abstract deliver(message: string): Promise<void>;
}

class EmailNotifier extends RetryingNotifier {
  protected async deliver(message: string): Promise<void> {
    console.log(`emailing: ${message}`);
  }
}

const n: Notifier = new EmailNotifier();
void n.send('hello');

go deeper

for a junior

Know the headline: an interface only describes a shape, while an abstract class can also carry real code that subclasses inherit, and a class may extend only one class.

for a middle

Explain the mechanics behind the choice — erasure versus an emitted class binding, structural satisfaction versus declared inheritance, public-only interface members versus protected abstract hooks, and one extends slot versus many interfaces.

for a senior

Argue the coupling cost in a real codebase: what breaks across consumers when a base class gains a member, why boundary contracts stay interfaces, and how to keep shared behaviour available without forcing inheritance on implementers.

for a principal

Own the extension-point strategy for a published library — whether to spend consumers' single inheritance slot at all, how a base class constrains your ability to evolve, and what a shape-only contract lets you change later that a base class does not.

## The question behind the question Interviewers ask this to see whether you reason about the *type layer versus the runtime*, or just recite a list. In TypeScript the honest framing is: an interface is a pure type-space declaration; an abstract class is a real JavaScript class that additionally refuses `new`. Everything else follows from that. ## What only an abstract class can do **Ship implementation.** Method bodies in the base are inherited by every subclass. This is the whole point of the template-method shape: ```ts abstract class Job { async run(): Promise<void> { const start = Date.now(); await this.execute(); console.log(`${this.name} took ${Date.now() - start}ms`); } protected abstract name: string; protected abstract execute(): Promise<void>; } ``` The timing and logging exist once. With an interface, every implementer writes them again. **Hold state and construction logic.** Fields, initialization, argument validation in the constructor — all run for every subclass instance through `super(...)`. An interface has no constructor and no storage. **Declare non-public members.** `protected abstract execute()` above is a hook visible to subclasses and invisible to callers. An interface cannot express that at all: interface members are public by definition, so the moment you put `execute` in an interface you have published it. **Exist as a value.** The class binding survives compilation, so it can be exported, referenced, stored in a registry, or used where a runtime class object is required. An interface name cannot be used in any value position. ## What only an interface can do **Cost nothing.** It emits no code. Nothing is added to the bundle, and there is no prototype link at runtime. **Be satisfied many times over.** A class has exactly one `extends` slot, and taking it is a permanent decision for the implementer; a class can satisfy any number of interfaces. If your contract is one of several a type might need, an abstract class is an expensive way to express it. **Be satisfied structurally, without a declaration.** TypeScript's type relationships are structural: an object literal or a class from a library you do not control can match an interface without ever naming it. Nothing can "accidentally" be a subclass of your abstract class — inheritance has to be written down. **Be reopened.** Interface declarations with the same name in the same scope merge, which is what makes ambient augmentation of third-party types possible. A class declaration cannot be extended after the fact this way. ## The costs, stated honestly Choosing an abstract class costs the implementer their single inheritance slot and binds them to your base class's future: add a member, and every subclass in every consumer is affected; change a protected hook's signature, and they all must follow. Choosing an interface costs you duplication — if there is real shared logic, each implementer writes it, and they will drift. That is the trade in one sentence: **an abstract class centralises code at the price of coupling; an interface avoids coupling at the price of duplication.** The broader design debate about inheritance versus composition is language-agnostic and lives with object-oriented design theory; what matters for a TypeScript answer is which mechanism actually expresses your intent in this type system. ## They are not exclusive The usual mature answer combines them: ```ts interface Notifier { send(message: string): Promise<void>; } abstract class RetryingNotifier implements Notifier { async send(message: string): Promise<void> { for (let attempt = 0; attempt < 3; attempt++) { try { await this.deliver(message); return; } catch { // retry } } throw new Error('delivery failed after 3 attempts'); } protected abstract deliver(message: string): Promise<void>; } ``` Consumers depend on `Notifier` — the erased shape, satisfiable by anything, mockable with an object literal in tests. Implementers may extend `RetryingNotifier` to inherit the retry loop, or may not. You get centralised behaviour without forcing it on anyone, and the published contract stays a shape. ## Practical decision rules - Contract crossing a module or package boundary, or a type consumers must satisfy: **interface**. - Genuine shared algorithm with named holes, used by a closed set of implementers you own: **abstract class**. - Need for `protected` members in the contract: **abstract class**, because interfaces cannot express visibility. - Need the contract to be satisfiable by types you do not control: **interface**, because structural matching does not require a declaration. - Anything you want to mock trivially in tests: an interface is easier — a plain object literal satisfies it, with no base constructor to run. ## The failure mode to avoid The common mistake is an abstract class with no implemented members at all — every member abstract, no fields, no constructor. That is an interface written with extra ceremony: it emits a class into the bundle, burns the implementer's `extends` slot, and buys nothing. If the base has nothing to give, make it an interface.

  • You have an abstract class in which every single member is abstract. What would you do with it?
    Turn it into an interface. With nothing implemented, the class contributes no behaviour, no state, and no constructor logic — it only emits a class into the bundle and consumes the implementer's single `extends` slot. The one reason to keep it is if you need `protected` members or a runtime value in the contract.
  • Which is easier to fake in a unit test, an interface or an abstract class, and why?
    An interface. Because TypeScript matches types structurally, a plain object literal with the right members satisfies it — no base constructor runs and no inheritance is needed. Faking an abstract class means writing a subclass, satisfying its constructor, and inheriting whatever the base does, which drags real behaviour into the test.
  • Can an abstract class implement an interface, and does that let it leave the interface's members unimplemented?
    Yes to both. An abstract class may declare `implements SomeInterface`, and because it is abstract it can satisfy a required member by declaring it `abstract` instead of writing a body — pushing the obligation onto its concrete subclasses. That is exactly the pattern for a base class that centralises part of a published contract.

saying these in an interview costs you the question

  • Says an abstract class is just an interface with a different keyword
  • Claims a class can extend several abstract classes
  • Thinks an interface can declare protected or private members
  • Believes an interface adds runtime overhead like a base class
  • Reaches for an abstract class when nothing is implemented in it

context