TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.
An abstract class in TypeScript is a class that can’t be instantiated directly — it exists to be extended, bundling shared implementation with one or more methods that subclasses are required to fill in. It sits between a plain class (fully implemented, ready to use) and an interface (pure shape, no implementation at all).
Basic syntax
Mark a class abstract, and optionally mark individual methods abstract too — those get a signature but no body:
abstract class Shape {
abstract area(): number;
describe(): string {
return `This shape has an area of ${this.area()}`;
}
}
class Circle extends Shape {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius ** 2;
}
}
new Shape(); // Error: Cannot create an instance of an abstract class
new Circle(4).describe(); // "This shape has an area of 50.26..."
Shape can’t be instantiated on its own — TypeScript rejects new Shape() at compile time. Circle, which provides a concrete area(), can be instantiated. describe() is fully implemented on the base class and inherited as-is; only area() is left for each subclass to define.
Abstract classes vs. interfaces
Both describe a contract that implementing types must satisfy, but they differ in what they’re allowed to carry.
| Abstract class | Interface | |
|---|---|---|
| Implementation | Can include concrete methods and fields | None — pure shape |
| Instantiation | Cannot be instantiated directly | N/A — not a runtime construct at all |
| Inheritance | Single inheritance (extends) | A class can implement many interfaces |
| Constructors | Can have one, called via super() | No constructors |
| Compiles to | Real JavaScript class (abstract keyword is erased, structure remains) | Erased entirely — no runtime trace |
| Access modifiers | Supports private/protected members | All members implicitly public |
The practical rule of thumb: reach for an interface when you’re describing a shape that unrelated classes might satisfy independently — no shared behavior, just a contract. Reach for an abstract class when several related classes share real, non-trivial implementation and you want to write that logic once rather than duplicating it across every subclass.
Why not just use a regular base class?
You technically can — nothing stops you from writing a normal class with a method that throws "not implemented" and expecting subclasses to override it. The difference is when the mistake gets caught. With an unenforced base class, forgetting to override a method is a runtime error, discovered only when that code path executes. With an abstract method, the compiler refuses to build until every concrete subclass provides an implementation — the same category of shift-left benefit you get from TypeScript’s type guards narrowing checks from runtime to compile time.
Marking the class itself abstract closes a second gap: it stops anyone from instantiating the base type directly and calling an abstract method that has no body, which would otherwise throw immediately at runtime.
A more realistic example
Abstract classes tend to earn their keep in scenarios with shared orchestration logic and a few points of required customization — parsers, connectors, and processing pipelines are common cases:
abstract class DataSource<T> {
async fetchAll(): Promise<T[]> {
const raw = await this.fetchRaw();
return raw.map((item) => this.parse(item));
}
protected abstract fetchRaw(): Promise<unknown[]>;
protected abstract parse(item: unknown): T;
}
class UsersSource extends DataSource<{ id: string; name: string }> {
protected async fetchRaw() {
const res = await fetch("/api/users");
return res.json();
}
protected parse(item: unknown) {
const u = item as { id: string; name: string };
return { id: u.id, name: u.name };
}
}
fetchAll() — the orchestration — is written once, on the base class. Each concrete source only has to supply fetchRaw() and parse(). Note the protected modifiers: those two methods are implementation details of the fetch pipeline, not something external callers should invoke directly. That combination of shared flow control plus enforced, encapsulated customization points is difficult to express cleanly with an interface alone, since an interface can’t provide fetchAll()’s body.
Where they get in the way
Abstract classes come with the same trade-offs as class inheritance generally: a subclass can only extend one abstract class, so if a type needs to satisfy two independent contracts, at most one of them can be an abstract class — the rest have to be interfaces. Deep abstract class hierarchies also tend to accumulate the classic inheritance problem of behavior that’s hard to trace, spread across several ancestor classes. If composition — building behavior out of small, combinable pieces — solves the problem as well as inheritance does, it’s usually the easier code to maintain later.
The takeaway
An abstract class lets you write shared implementation once while forcing subclasses to fill in the pieces that must vary, with the compiler enforcing it rather than a runtime check. Use one when related classes share real behavior and you want required customization points caught at build time; reach for a plain interface when you’re only describing a shape, and prefer composition over deep inheritance chains once a hierarchy starts getting hard to follow.
Keep reading
Takina · · 4 min read Structural Typing vs Nominal Typing in TypeScript
Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.
Takina · · 5 min read TypeScript Variance Explained
Variance decides when TypeScript accepts Array<Dog> where Array<Animal> is expected — covariant, contravariant, or invariant, explained with examples.
Takina · · 5 min read Zod vs Yup: TypeScript Schema Validation Compared
Zod infers static TypeScript types directly from its schemas; Yup was built for JavaScript form validation first. How the two approaches differ.