Ch. 2 · TypeScript

TypeScript Abstract Classes: What They Enforce and When

What TypeScript's abstract keyword enforces, why it vanishes at runtime, and when a template-method base class beats an interface.

~7 min readadvancedupdated Oct 19, 2026

Say you integrate three payment gateways. Each one has its own HTTP call, but all three need the same input validation, the same retry loop and the same log line. You could copy that wrapper three times, or write a helper function each provider has to remember to call. An abstract class gives you a third option: the base class owns the wrapper, and the compiler refuses to build a provider that doesn’t supply the gateway call.

That’s the honest pitch for abstract. Interviewers like this topic because candidates often know the keyword but get the details wrong: what it checks, what it doesn’t, and how it interacts with TypeScript’s structural type system.

What the compiler enforces

An abstract class can declare members without bodies. Two rules follow, and both are checked only by tsc:

  1. You can’t call new on the abstract class itself.
  2. A subclass that isn’t also marked abstract must implement every abstract member it inherits.

Here’s a small file that breaks both rules, plus two more that people trip over:

abstract class Job {
  abstract readonly queue: string;
  constructor() {
    console.log(this.queue.toUpperCase());
  }
  abstract run(): Promise<void>;
}

class EmailJob extends Job {
  readonly queue = 'email';
}

const j = new Job();

function make<T>(ctor: new () => T): T {
  return new ctor();
}
make(Job);
TypeScript

Compiled with tsc --strict, TypeScript 7.0 reports:

job.ts(4,22): error TS2715: Abstract property 'queue' in class 'Job' cannot be accessed in the constructor.
job.ts(9,7): error TS2515: Non-abstract class 'EmailJob' does not implement inherited abstract member run from class 'Job'.
job.ts(13,11): error TS2511: Cannot create an instance of an abstract class.
job.ts(18,6): error TS2345: Argument of type 'typeof Job' is not assignable to parameter of type 'new () => Job'.
  Cannot assign an abstract constructor type to a non-abstract constructor type.
Text

TS2515 and TS2511 are the two rules above. TS2715 is the compiler protecting you from an ordering bug, which gets its own section below. TS2345 shows that the class value itself has a different type: typeof Job is an abstract constructor, so it won’t fit a parameter typed new () => T. If a factory or DI container genuinely needs to accept abstract classes as tokens, type the parameter as abstract new () => T. That signature accepts both concrete and abstract classes, but the function can’t call new on it, which is exactly right for a lookup key.

What disappears when the code runs

abstract has no JavaScript equivalent. The emitted output is a normal class, and the abstract members simply aren’t there. A plain JavaScript caller, or a TypeScript caller who casts to any, can instantiate it without complaint. If the class is published in a library consumed from JavaScript and you need a runtime guarantee, add one yourself:

abstract class Guarded {
  constructor() {
    if (new.target === Guarded) throw new TypeError('Guarded is abstract');
  }
}
try {
  new (Guarded as any)();
} catch (e) {
  console.log((e as Error).message);
}
TypeScript

Running it with Node 22 prints Guarded is abstract. Most application code doesn’t need this, but saying “abstract is erased, so it’s a compile-time contract” in an interview shows you understand where TypeScript stops.

A base class that earns its keep

Back to the payment gateways. The base class carries the validation, the retry loop and the logging. Subclasses supply one method.

type ChargeResult = { id: string; amountCents: number };

abstract class PaymentProvider {
  abstract readonly name: string;
  protected abstract charge(amountCents: number): Promise<ChargeResult>;

  async pay(amountCents: number, attempts = 3): Promise<ChargeResult> {
    if (!Number.isInteger(amountCents) || amountCents <= 0) {
      throw new RangeError(`amount must be a positive integer, got ${amountCents}`);
    }
    for (let attempt = 1; ; attempt++) {
      try {
        const result = await this.charge(amountCents);
        console.log(`${this.name}: charged ${amountCents} on attempt ${attempt}`);
        return result;
      } catch (err) {
        console.log(`${this.name}: attempt ${attempt} failed (${(err as Error).message})`);
        if (attempt >= attempts) throw err;
      }
    }
  }
}

class FlakyCardProvider extends PaymentProvider {
  readonly name = 'card';
  private calls = 0;
  protected async charge(amountCents: number): Promise<ChargeResult> {
    this.calls++;
    if (this.calls < 3) throw new Error('503 from gateway');
    return { id: `ch_${this.calls}`, amountCents };
  }
}

console.log(await new FlakyCardProvider().pay(1999));
TypeScript

This compiles cleanly under --strict, and Node prints:

card: attempt 1 failed (503 from gateway)
card: attempt 2 failed (503 from gateway)
card: charged 1999 on attempt 3
{ id: 'ch_3', amountCents: 1999 }
Text

A few decisions in that code are worth explaining out loud.

pay is public and concrete, charge is protected and abstract. This is the template method pattern: callers get one entry point, and the step that varies is hidden. Making charge protected means nobody outside the hierarchy can call the gateway and skip validation. If it were public, the base class could no longer promise that every charge was validated and retried.

name is an abstract property, not a constructor argument. Each subclass declares it as a field. That keeps the constructor signature empty, but it also creates the trap described next.

The retry count is a parameter with a default, not a subclass override. It’s tempting to make every knob an overridable method. Each override point is a promise to future subclass authors that changing it won’t break the base class. Keep the list short.

Constructors run before subclass fields

The TS2715 error earlier is TypeScript catching one case of a wider problem. When you call new FlakyCardProvider(), JavaScript runs the PaymentProvider constructor first, and only after it returns are FlakyCardProvider’s field initialisers (name = 'card', calls = 0) applied. The compiler blocks direct reads of abstract properties in the base constructor, but it doesn’t stop you calling an abstract method that reads a subclass field:

abstract class Widget {
  constructor() {
    this.render();
  }
  protected abstract render(): void;
}

class Label extends Widget {
  private text = 'Total';
  protected render(): void {
    console.log(`render: ${this.text}`);
  }
}

new Label();
TypeScript

This passes tsc --strict with no errors and prints render: undefined. Strict property initialisation can’t help, because from the compiler’s point of view text is initialised. The fix is to keep constructors free of calls to overridable methods: have callers invoke an explicit init() or render() after construction, or pass the data the base needs as constructor arguments. Interviewers who’ve been bitten by this in UI frameworks will ask about it.

Abstract classes are still structural types

A common claim is that an abstract class forces nominal typing, so only real subclasses fit. That’s only half true. This assignment compiles without errors:

const fake: Job = { queue: 'test', run: async () => {} };
TypeScript

Job has only public members, so any object with the same shape is assignable. TypeScript only becomes nominal when the class has a private or protected member, because those members are tied to the declaring class:

abstract class Repo {
  protected abstract table: string;
  abstract find(id: string): unknown;
}
const notOk: Repo = { table: 'users', find: () => null };
TypeScript
repo.ts(5,7): error TS2322: Type '{ table: string; find: () => null; }' is not assignable to type 'Repo'.
  Property 'table' is protected but type '{ table: string; find: () => null; }' is not a class derived from 'Repo'.
Text

This matters for testing. If your service depends on PaymentProvider, a test can’t pass a plain object, because charge is protected. It has to subclass the base or cast. That’s often a reason to split the contract in two: an interface that consumers depend on, and an abstract class that implementations may extend for the shared plumbing.

interface Payments {
  pay(amountCents: number): Promise<ChargeResult>;
}
abstract class PaymentProvider implements Payments { /* as above */ }
TypeScript

Checkout code takes Payments, tests pass { pay: async () => ({ id: 'x', amountCents: 1 }) }, and real gateways still inherit the retry loop.

Choosing between abstract class, interface and composition

A rough guide that holds up in code review:

  • Only a shape is shared. Use an interface or a type alias. It costs nothing at runtime and a class can implement several.
  • An algorithm is shared and one step varies. An abstract class with a template method is a good fit, as with pay and charge.
  • Several independent behaviours are shared. A class can extend only one base. If gateways need retry, rate limiting and metrics in different combinations, pass collaborators in (a RetryPolicy, a Limiter) or wrap a function, rather than building RetryingRateLimitedProvider.
  • You need to share fields with invariants. Abstract classes can hold protected state and validate it in one place, which interfaces can’t.

The failure mode to name is the deep hierarchy: an abstract base, an abstract middle layer “for card providers”, and concrete classes that override half the base’s methods. At that point no one can predict what pay does without reading four files.

Interview exercise: export jobs

You’re reviewing this design. Three export jobs (CSV, PDF, Parquet) each open a temporary file, write rows, upload the file to object storage, and delete the temp file even if the upload fails. The author made ExportJob an interface with open, write, upload and cleanup methods, and each class implements all four. What would you change, and what’s the risk in your change?

A reasoned answer

Three of the four steps (open, upload, cleanup) are identical, and the important invariant (cleanup always runs) currently lives in three places, so one of them will eventually forget the finally. I’d introduce an abstract ExportJob with a concrete public run() that opens the temp file, calls a protected abstract writeRows(file), uploads, and deletes the file in a finally block. Each format then implements only writeRows. I’d keep a small Exporter interface with run(): Promise<void> for the scheduler to depend on, so tests of the scheduler can pass a plain object.

The risks: writeRows must not be called from the constructor, because subclass fields such as a CSV delimiter won’t be set yet. And if a fourth format later needs a different upload path (say, streaming instead of a temp file), resist adding a shouldUseTempFile() hook to the base. That’s the moment to compose an uploader instead of growing the hierarchy.

More in TypeScript

read ✓TypeScript · mid

TypeScript Enums vs Union Types

What TypeScript enums compile to, why string enums reject plain strings from JSON, and when an as const object beats both options.

~6 min readread →
esc