Skip to content

9.2.6 — Interfaces, Abstract Classes and Enums

Two engineers on the delivery team both need to describe "something that can deliver an order". One writes this:

typescript
interface DeliveryChannel {
  deliver(order: Order): Promise<DeliveryResult>;
  estimatedMinutes(order: Order): number;
}

The other writes this:

typescript
abstract class DeliveryChannel {
  abstract deliver(order: Order): Promise<DeliveryResult>;
  abstract estimatedMinutes(order: Order): number;
}

They look almost identical, and in a code review most people wave both through. They are not the same, and the difference decides what you can do for the next three years. This page settles it, and then deals with enums, which is where the same choice gets made badly most often.

1. What each one actually is

An interface is a contract with no code. It says what methods exist and what they take and return. It has no bodies, no fields with values, no constructor. It cannot be created with new. In TypeScript it vanishes completely when compiled — there is no DeliveryChannel in the emitted JavaScript, not even an empty object.

An abstract class is a partially built class. It can hold state, define constructors, provide working methods that subclasses inherit, and declare abstract methods that subclasses must supply. It cannot be created directly, but it exists at runtime and it participates in the prototype chain.

The difference in one sentence: an interface says what you must be able to do; an abstract class does some of it for you and leaves holes.

typescript
abstract class BaseDeliveryChannel implements DeliveryChannel {
  constructor(protected readonly tracker: TrackingService) {}      // (1) state + constructor

  async deliver(order: Order): Promise<DeliveryResult> {           // (2) real, shared code
    const id = await this.dispatch(order);                         // (3) the hole
    await this.tracker.record(order.id, id);                       // (4) shared afterwards
    return DeliveryResult.dispatched(id);
  }

  protected abstract dispatch(order: Order): Promise<DispatchId>;  // (5)
  abstract estimatedMinutes(order: Order): number;
}

Line (1) shows the first thing an interface cannot do: hold a field with a value and take constructor arguments. Line (2) shows the second: provide a working method body that every subclass gets for free. Line (3) calls the hole, and line (4) runs shared work afterwards, so every channel records tracking whether its author remembered to or not. Line (5) declares the hole that subclasses must fill.

That shape — shared skeleton, small holes — is the Template Method pattern (9.4.17), and it is the main legitimate reason to reach for an abstract class.

2. The decision rule

Most guidance on this is a feature comparison table, which tells you what each one can do without telling you which to pick. Here is the rule that actually decides it:

Use an interface to say what something can do. Use an abstract class when you have real shared code that several implementations must run identically, and the holes in it are small.

When both fit, choose the interface. It costs less and constrains less.

Two properties make the interface the safer default.

A class can implement many interfaces but extend only one class. If BikeDelivery extends BaseDeliveryChannel, its single inheritance slot is spent forever. If later it also needs to be a Schedulable and a Trackable, interfaces cost nothing and the abstract class would have cost you a redesign.

Inheriting brings the fragile base class problem with it. Every subclass of an abstract class depends on its internal call structure, and 9.2.4 showed how silently that breaks. An interface has no internals, so it cannot break anybody.

The comparison, for when you need the specifics:

InterfaceAbstract class
Method bodiesnoyes
Fields with valuesnoyes
Constructornoyes
How many per classany numberone
At runtimeeraseda real class
You depend ona contracta contract plus internals
Retrofit onto existing classesyesno

That last row is the one people forget, and in TypeScript it is decisive. Because types are matched by shape rather than by declaration (3.7.2), you can define an interface today and have every existing class satisfy it immediately, including classes from a library you do not control:

typescript
interface Closeable { close(): Promise<void>; }        // (1)

async function shutdown(things: Closeable[]): Promise<void> {
  for (const t of things) await t.close();
}

await shutdown([dbPool, kafkaProducer, httpServer]);   // (2)

Line (1) invents a role. Line (2) passes three objects from three different libraries, none of which has ever heard of Closeable. They match because they each have a close method returning a promise. Doing this with an abstract class would require rewriting all three libraries.

One more use for abstract classes that is easy to miss: they can enforce an invariant that every subclass must obey, because the base constructor runs no matter what. If every delivery channel must register itself, or must validate its configuration, an abstract class can guarantee it and an interface cannot. That is a real reason, and it is worth being explicit that it is the reason when you use it.

3. interface or type in TypeScript?

TypeScript has a second way to name a shape, and the two overlap enough to cause arguments.

typescript
interface Order { id: OrderId; total: Money; }
type OrderT = { id: OrderId; total: Money; };          // almost the same thing

For plain object shapes they are interchangeable, and either is fine. The differences that actually matter:

type can express things interface cannot. Unions, intersections, tuples, and anything computed from another type:

typescript
type MethodKind = "card" | "upi" | "wallet" | "cod";        // (1) union — interface cannot
type Readonly<T> = { readonly [K in keyof T]: T[K] };       // (2) mapped — interface cannot
type Handler = (e: OrderEvent) => Promise<void>;            // (3) function type, cleaner

Line (1) is the single most common reason to reach for type, and section 5 is about exactly this. Line (2) is type-level computation (3.7.5). Line (3) is expressible either way, but reads better as a type.

interface merges declarations and type does not. Two interface Foo declarations in the same scope combine into one. This is how you add a property to Express.Request or to Window from your own code, and it is genuinely useful for extending types you do not own. It is also a hazard, because a duplicate interface name silently merges instead of erroring.

The working rule: interface for object shapes and roles that classes implement, type for unions, function types and anything computed. If you find yourself unable to express something with interface, that is the signal, not a style violation.

4. Implementing several roles

A class can play more than one role, and this is where interfaces pull decisively ahead.

typescript
interface Deliverable { deliver(order: Order): Promise<DeliveryResult>; }
interface Trackable { currentLocation(): Promise<GeoPoint>; }
interface Schedulable { availableSlots(day: Date): TimeSlot[]; }

class BikeCourier implements Deliverable, Trackable, Schedulable {   // (1)
  async deliver(order: Order) { /* … */ }
  async currentLocation() { /* … */ }
  availableSlots(day: Date) { /* … */ }
}

class DroneDelivery implements Deliverable, Trackable {              // (2) no scheduling
  async deliver(order: Order) { /* … */ }
  async currentLocation() { /* … */ }
}

Line (1) declares that a bike courier plays three roles. Line (2) shows the payoff: a drone plays two of them and simply does not claim the third. With one fat DeliveryChannel interface containing all three concerns, DroneDelivery would be forced to write an availableSlots method that throws or returns an empty array, and that stub is a lie any caller can trip over.

That is the argument for keeping roles small, and 9.3.8's Interface Segregation Principle is this observation turned into a rule. The practical version: a role is what one kind of caller needs, not everything one kind of object can do. Code that only needs to track something takes a Trackable, and it works with couriers, drones and anything invented next year.

Note also what implements does and does not do in TypeScript. It is a check, not a declaration: it tells the compiler to verify that this class matches the interface, and it produces a clear error at the class rather than at the call site if it does not. It creates no runtime relationship at all. A class that matches the shape satisfies the interface with or without the keyword. Write implements anyway, because the error messages are far better when the class is wrong.

5. Enums, honestly

Now the part where the same decision gets made badly. TypeScript's enum is one of the few features that adds runtime code, and it has enough sharp edges that a lot of teams ban it. Understanding why also teaches you what a closed set of values should look like.

Numeric enums are the worst version:

typescript
enum Status { Placed, Preparing, Delivered }     // (1) 0, 1, 2

const s: Status = 47;                            // (2) accepted! no error.
console.log(Status.Placed);                      // → 0
JSON.stringify({ status: Status.Placed });       // → {"status":0}   (3)

Line (1) assigns numbers automatically. Line (2) is the real problem: any number is assignable to a numeric enum type, so the type gives you almost no protection. Line (3) shows the operational problem: the number goes into your database and your API responses, so a log line says status: 0 and nobody can read it, and inserting a new status in the middle silently renumbers every stored row.

String enums fix most of that:

typescript
enum Status {
  Placed = "placed",
  Preparing = "preparing",
  Delivered = "delivered",
}
const s: Status = "placed";      // Type '"placed"' is not assignable to type 'Status'

Values are readable everywhere, and arbitrary values are rejected. The remaining friction is on the error line: a plain string is not assignable to the enum type, so every boundary where data arrives from JSON needs an explicit conversion, and it is nominal rather than structural, which is unlike the rest of TypeScript.

The alternative most TypeScript codebases now prefer is a union of string literals:

typescript
type Status = "placed" | "preparing" | "delivered";          // (1)

const STATUSES = ["placed", "preparing", "delivered"] as const;   // (2)
type StatusFromList = typeof STATUSES[number];                    // (3) same union, derived

function next(s: Status): Status {
  switch (s) {
    case "placed": return "preparing";
    case "preparing": return "delivered";
    case "delivered": return "delivered";
  }
}

Line (1) is the whole type. It compiles to nothing, works directly with JSON because the values are strings, and gives you exhaustiveness checking in switch (3.7.3).

Line (2) handles the one thing a bare union cannot do, which is give you the list at runtime for validation or for rendering a dropdown. as const makes the array readonly and its elements literal types rather than string. Line (3) derives the union back out of the array, so the list and the type can never drift apart. One source of truth, both a runtime value and a compile-time type.

One case where enum still wins: when you need a namespace of related constants that are genuinely referenced as Status.Placed throughout a large codebase and you want renaming to be safe. A const object with as const gives you the same thing without the runtime oddities, so this is a preference rather than a requirement.

6. The enum smell: when a closed set should have been polymorphism

Here is the design point that makes this section belong in an OOP chapter rather than a TypeScript one.

An enum describes what something is. The moment you find yourself branching on that enum in several places to decide what something does, you have the six-switches problem from 9.2.5 again:

typescript
// ❌ the enum is now driving behaviour, in more than one place
function feeFor(kind: MethodKind): Money {
  switch (kind) { /* … */ }
}
function iconFor(kind: MethodKind): IconName {
  switch (kind) { /* … */ }
}
function requiresOtp(kind: MethodKind): boolean {
  switch (kind) { /* … */ }
}

Three switches over the same set means adding a payment method requires three edits, in three files, with nothing linking them. Two cures, and picking between them is the real skill.

Cure one: give the values objects. If the behaviours are substantial, make each value a class implementing a role, exactly as 9.2.5 did. The enum shrinks to a lookup key used at one place, the registry.

Cure two: put the data in one table. If the "behaviours" are really just attributes, do not build classes for them. Build one table:

typescript
const METHODS = {
  card:   { fee: 200, icon: "credit-card", requiresOtp: true },     // (1)
  upi:    { fee: 0,   icon: "upi",         requiresOtp: false },
  wallet: { fee: 0,   icon: "wallet",      requiresOtp: false },
  cod:    { fee: 500, icon: "cash",        requiresOtp: false },
} as const satisfies Record<MethodKind, MethodInfo>;                // (2)

Line (1) puts everything about one payment method on one line, so adding PayPal is one line and it is impossible to add it to the fee table and forget the icon table. Line (2) uses satisfies, which checks that the object matches Record<MethodKind, MethodInfo> — so a missing method is a compile error — while keeping the precise literal types for each value, which a plain type annotation would have widened away (3.7.3 covers satisfies).

How to choose. If the per-value behaviour is data, use the table — it is smaller, easier to read as a whole, and easy to move into configuration later. If the per-value behaviour is logic with its own dependencies, use classes. The tell is whether you can write the value on one line. fee: 200 fits on a line, and "authorize this against a gateway with retries and idempotency keys" does not.

7. Putting the three together in one design

The delivery app ends up with all three tools, each doing what it is good at:

typescript
type ChannelKind = "bike" | "drone" | "partner";        // (1) closed set of names

interface Deliverable {                                 // (2) the role callers depend on
  deliver(order: Order): Promise<DeliveryResult>;
  estimatedMinutes(order: Order): number;
}

abstract class TrackedChannel implements Deliverable {  // (3) shared skeleton, small holes
  constructor(protected readonly tracker: TrackingService) {}

  async deliver(order: Order): Promise<DeliveryResult> {
    const id = await this.dispatch(order);
    await this.tracker.record(order.id, id);
    return DeliveryResult.dispatched(id);
  }
  protected abstract dispatch(order: Order): Promise<DispatchId>;
  abstract estimatedMinutes(order: Order): number;
}

class BikeCourier extends TrackedChannel { /* … */ }
class DroneDelivery extends TrackedChannel { /* … */ }

const CHANNELS: Record<ChannelKind, Deliverable> = {     // (4) one place maps name to object
  bike: new BikeCourier(tracker, dispatcher),
  drone: new DroneDelivery(tracker, droneApi),
  partner: new PartnerChannel(tracker, partnerApi),
};

Line (1) is the union, used for data that crosses boundaries: it is what arrives in JSON and what is stored in the database. Line (2) is the interface, and it is what every caller in the application depends on — no caller mentions TrackedChannel or any concrete class. Line (3) is the abstract class, used only where there is genuinely shared code, and note that it implements the interface rather than replacing it. Line (4) is the single place where a name becomes an object, typed with Record so that adding a value to ChannelKind and forgetting to register it is a compile error.

The result is that a new channel is one class plus one line, callers are unaffected, tests inject a fake Deliverable with no framework, and the string that travels through the database is readable.

What the next page adds. You now have several classes that know about each other. 9.2.7 is about the kinds of relationship between them — which object owns which, which merely uses which — and the UML notation for drawing it, which is what an LLD interview asks you to produce on a whiteboard.

Recall

  • An interface says what you must be able to do; an abstract class does part of it for you and leaves holes. When both fit, choose the interface: a class can implement many interfaces but extend only one class, and inheriting brings the fragile base class risk with it.
  • The abstract class earns its place when there is real shared code several implementations must run identically (the Template Method shape) or an invariant every subclass must obey, since its constructor always runs.
  • In TypeScript, interfaces are erased at compile time and matched structurally, so you can define a role today and have existing classes — even from libraries you do not own — satisfy it with no edits. implements is a check that improves error messages, not a runtime relationship.
  • interface versus type: use type for unions, function types and computed types; use interface for object shapes and roles. Interfaces merge when declared twice, which is how you extend types you do not own and also an easy way to collide by accident.
  • Enums honestly: numeric enums accept any number and put unreadable integers in your database. String enums fix that but are nominal, so JSON needs conversion. A union of string literals plus an as const array is the usual best answer: no runtime code, works directly with JSON, exhaustiveness checking, and one source of truth for both the type and the list.
  • The enum smell: the moment you branch on the same enum in several places to decide behaviour, either give the values objects (when the behaviour is logic) or put everything in one table with satisfies Record<Kind, Info> (when the behaviour is data). The tell is whether a value's behaviour fits on one line.

Self-test: Give the one-line rule for choosing between an interface and an abstract class, and the two reasons the interface is the safer default. Why can you retrofit an interface onto a library class in TypeScript but not an abstract class? Name two things type can express that interface cannot. What exactly goes wrong with a numeric enum? When does a closed set of values want classes instead of a table?

Quiz Bank

FoundationalInterface or abstract class — how do you choose?

The rule: use an interface to declare what something can do; use an abstract class when you have real shared code that every implementation must run identically, and the holes left for subclasses are small. When both fit, take the interface.

Two reasons the interface is the safer default. A class can implement any number of interfaces but extend exactly one class, so an abstract class spends a slot that can never be recovered — and the need for a second role usually arrives later, when redesigning is expensive. And inheritance brings the fragile base class problem: subclasses end up depending on the parent's internal call structure, which is undeclared and breaks silently (9.2.4). An interface has no internals, so it cannot break anyone.

What genuinely justifies the abstract class. Shared code with small holes, which is the Template Method shape — a retry loop, a transaction wrapper, a "do this, call the hole, then always record tracking" skeleton. And enforcing an invariant at construction, since a base constructor runs for every subclass whether its author remembered or not.

In TypeScript specifically, one more argument tips it further. Interfaces are structural and erased, so you can define one today and every matching class satisfies it immediately, including classes from libraries you cannot modify. An abstract class requires every implementation to be rewritten to extend it. The common design is to use both: the interface is what all callers depend on, and the abstract class is an optional convenience that implements it.

FoundationalWhat is wrong with TypeScript enums, and what do you use instead?

Numeric enums, the default form, have two real problems. Any number is assignable to the enum type, so const s: Status = 47 compiles, which means the type provides much weaker protection than it appears to. And the values that travel are integers, so your API responses, logs and database rows say status: 0, which nobody can read and which silently renumbers everything stored if a value is inserted in the middle later.

String enums fix both, since values are readable everywhere and arbitrary values are rejected. What remains is that they are nominal rather than structural, unlike everything else in TypeScript, so a plain "placed" coming from JSON is not assignable and every boundary needs an explicit conversion. They also emit runtime code, which is a small cost.

The usual replacement is a union of string literals: type Status = "placed" | "preparing" | "delivered". It compiles to nothing, the values are exactly what appears in JSON so no conversion is needed at the boundary, and it gives full exhaustiveness checking in a switch.

Its one gap is that you cannot list the values at runtime, which you need for validation and for rendering dropdowns. Fix that by declaring the array with as const and deriving the type from it with typeof STATUSES[number], so the runtime list and the compile-time type come from one source and cannot drift.

When enum is still fine: a large codebase that already uses Status.Placed everywhere and values renaming safely. A const object with as const gives the same thing without the odd runtime behaviour, so it is a preference rather than a necessity.

AppliedYou find three separate switch statements over the same payment-method union. What do you do, and how do you choose between the two fixes?

The diagnosis: the union is describing what something is, and three places are branching on it to decide what something does. Adding a payment method now means three edits in three files with nothing linking them, which is the shotgun surgery smell from 9.1.

Fix one, give the values objects. Define the role, write a class per method, and let each answer for itself. The union survives only as a lookup key, used in exactly one registry that maps a stored string to an object.

Fix two, put everything in one table. One object with a row per method, holding every attribute the switches were computing, typed with satisfies Record<MethodKind, MethodInfo> so a missing method fails to compile while the precise literal types are preserved.

How to choose, which is the part that matters. Look at what each branch produces. If it is a value that fits on one line — a fee, an icon name, a boolean flag, a label — use the table. It is smaller, the whole set is visible at once, and adding a method is physically impossible to do halfway because all its attributes are on one line. If it is logic with its own dependencies — talk to a gateway, retry, produce an idempotency key, handle vendor errors — use classes, because a table cell cannot hold that and pushing it in produces functions stored in objects, which is a class with worse tooling.

Mixed cases are common and fine. Use the table for the display attributes and classes for the behaviour. What matters is that each concern has exactly one place where all the variants are listed together, so adding a variant is a single visible edit rather than a hunt.

InterviewIn TypeScript, why can you add an interface to a class you do not control, and why does that change the design advice inherited from Java?

Because TypeScript's type system is structural. Two types are compatible when their shapes match, not when one was declared to implement the other (3.7.2). So you can write interface Closeable { close(): Promise<void> } today and pass in a database pool, a Kafka producer and an HTTP server from three different libraries, none of which has heard of your interface. implements is only a compile-time check that produces better error messages; it creates no runtime relationship and is not required for compatibility.

Why this changes the advice. In Java and C#, types are nominal: retrofitting an interface means editing every implementing class, and for library classes you cannot. That made extracting interfaces early genuinely cheaper than extracting them late, and combined with older mocking frameworks that could only mock interfaces, it produced the habit of one interface per class. That habit then travelled into TypeScript codebases where neither reason applies.

What to do instead. Write the class first. When a second implementation or a painful test actually arrives, define the interface then, shaped by two real cases rather than one guess, and no existing class needs to change. The waiting is nearly free, and what you get is a better-shaped role, because interfaces designed from a single implementation tend to be that implementation with the word "interface" in front.

The bound: interfaces still earn their keep up front at genuine architectural boundaries — the line between domain and infrastructure, or a module you intend to defend against outside coupling — because there the interface is a decision about the shape of the system, not a guess about future implementations.