Many TypeScript teams treat abstract class and interface as interchangeable fillings for the same sandwich. They are not. Reaching for the wrong one produces real scars: duplicated business logic, rigid class trees that resist every refactor, and subtle runtime errors that trace back to a base class someone assumed was safe. The fix is not memorizing a rulebook. It is learning to look at your actual requirements and pick the tool that matches them.

Interfaces Are Pure Contracts

An interface is a compile-time boundary. It describes what an object must look like and what it must be able to do, but it ships zero implementation. After TypeScript compiles down to JavaScript, the interface vanishes entirely. It leaves no constructor, no prototype chain, and no extra bytes in your bundle.

Imagine you are building a notification system. You define an interface:

interface Notifier {
  send(message: string): void;
  readonly channel: string;
}

Any class, or even a plain object literal, that satisfies that shape is a valid Notifier. An EmailNotifier can implement it. So can an SmsNotifier, a SlackNotifier, or a mock object you hand to a unit test. Because the interface carries no code, each implementation writes its own send method from scratch. That is exactly what you want when the implementations share nothing but their public face.

Abstract Classes Carry Real Code

An abstract class is a full-fledged class that happens to forbid direct instantiation. It can define fields, concrete methods with logic, and constructor logic. It forces subclasses to fill in the blanks through abstract methods, but it also gives them inherited behavior they do not have to write themselves.

Picture a data access layer. Every repository in your application needs to parse an identifier, validate it against a schema, and only then run a storage-specific query. An interface cannot capture that shared sequence because an interface cannot contain executable code. An abstract class can:

abstract class BaseRepository<T> {
  protected validateId(id: string): boolean {
    return /^[a-z0-9\-]+$/.test(id);
  }

  abstract fetchById(id: string): Promise<T | null>;

  async findById(id: string): Promise<T | null> {
    if (!this.validateId(id)) throw new Error("Invalid ID format");
    return this.fetchById(id);
  }
}

BaseRepository enforces structure. It demands that subclasses implement fetchById. But it also supplies working logic. Subclasses get the guardrails and the boilerplate for free. If you tried to replace this with an interface, you would end up copying validateId and findById into every single repository. That is not abstraction. It is a maintenance tax.

The One Question That Decides It

Your choice should always come down to a single question: Does this contract need to ship shared code?

If the answer is no, use an interface. If the answer is yes, use an abstract class.

Getting this wrong has immediate consequences. If you use an abstract class where a shape would suffice, you force every implementation into an inheritance chain. Unit tests suddenly require awkward mocking or partial stubs of a real class. Third-party extensions must subclass your base rather than simply matching a shape. You have turned a simple contract into a mountain.

On the flip side, if you use an interface where shared behavior exists, you scatter copies of the same logic across your codebase. When that logic contains a bug, you do not fix it in one place. You hunt through ten implementations and hope you do not miss the eleventh.

How They Actually Differ in Practice

Beyond the philosophical split, three practical gaps separate the two.

Performance and bundle size. Interfaces are erased during compilation. They add exactly zero weight to your JavaScript output and consume no runtime memory. Abstract classes compile down to real constructor functions and prototype chains. Each one you define adds to your bundle and sits in memory when instantiated. On a server handling thousands of instances, or a frontend bundle under scrutiny, that difference is not theoretical.

Flexibility of composition. A single class can implement many interfaces at once. You might build a FileCache that implements Cache, Disposable, and EventEmitter in the same breath. TypeScript is happy because interfaces impose no hierarchy. A class, however, can only extend one abstract class. JavaScript's prototype chain does not support multiple inheritance. If you lean too heavily on abstract classes, you will eventually face the classic dilemma of trying to merge two base classes that both contain