Skip to main content

Abstract classes in TypeScript

Abstract class in TypeScript is a class you cannot instantiate directly. It acts as a shared base for subclasses, combining concrete methods (real implementation every subclass inherits) with abstract methods (required overrides each subclass must fill in). One place for both the contract and the shared code.

Theory

TL;DR

  • Blueprint analogy: defines required rooms (abstract methods) and pre-built walls (concrete methods), but you cannot live in the blueprint itself
  • Core difference from interface: abstract class provides partial implementation alongside contracts; interfaces are contracts only, no code
  • TypeScript enforces abstract members at compile time; at runtime V8 sees a plain ES6 class
  • Decision rule: use when 2+ subclasses share real implementation logic; use interface when you only need a structural shape

Quick example

typescript
abstract class Animal { // Concrete: shared by every subclass automatically move(): string { return `${this.sound()} while moving`; } // Abstract: each subclass must implement this abstract sound(): string; } class Dog extends Animal { sound(): string { return 'Woof!'; } } const dog = new Dog(); dog.move(); // 'Woof! while moving' // new Animal(); // Error: Cannot create an instance of an abstract class

move() lives once in the base. Every subclass gets it without extra code. sound() forces each subclass to define its own behavior. That is the entire point.

Abstract class vs interface

Both describe shape. Only abstract classes carry code.

An interface declares what a type looks like: method signatures, property names, return types. Nothing runs at runtime because TypeScript erases interfaces completely during compilation. An abstract class, by contrast, does compile to JavaScript. Its concrete methods become real class methods. Its abstract members become required overrides that TypeScript checks before emitting any JS.

The practical split: if two or more classes need shared logic, use an abstract class. If you only need a structural contract (for function parameters, return types, or decoupled modules), use an interface. Most codebases use both together.

FeatureAbstract classInterface
Concrete implementationYesNo
ConstructorYesNo
Access modifiersYes (public, protected, private)No
Properties with valuesYesNo
Multiple inheritanceNo (single extends)Yes (multiple implements)
Exists at runtimeYes (compiles to JS class)No (erased at compile time)

When to use

  • Shared logic across subclasses: a base Repository with a concrete findById that all repos inherit
  • Template method pattern: define the algorithm skeleton in the base, let subclasses fill specific steps
  • Hierarchy with state: you need properties, constructors, and methods together in one base
  • Factory patterns: a base class for dynamically creating and returning subclass instances

Skip abstract classes when you only need a contract (interface works), when the class is used in exactly one place, or when you are writing functional React code where interfaces compose more naturally.

How TypeScript compiles abstract classes

The TypeScript compiler (tsc) strips the abstract keyword and emits a standard ES6 class. Concrete methods become regular class methods. Abstract member declarations disappear entirely. The compiler checks that every subclass implements all abstract members before generating any JS output.

At runtime, V8 or Node sees nothing special. The enforcement is purely TypeScript, gone by the time your code actually runs.

Common mistakes

1. Trying to instantiate the abstract class directly

typescript
abstract class A { abstract greet(): string; } const a = new A(); // TS2511: Cannot create an instance of an abstract class

Fix: extend it and implement the abstract method, then instantiate the subclass.

2. Forgetting to implement all abstract members

typescript
abstract class A { abstract greet(): string; abstract farewell(): string; } class B extends A { greet() { return 'Hi'; } // Missing farewell() -> TS2515: Non-abstract class 'B' does not implement... }

Fix: implement every abstract member, or mark B itself as abstract.

3. Exposing a protected abstract override as public unintentionally

typescript
abstract class Base { protected abstract init(): void; } class Derived extends Base { public init(): void { console.log('ready'); } // TypeScript allows widening to public, but... }

This compiles. But it exposes an internal lifecycle hook as part of the public API. Users of Derived can now call init() directly, which breaks encapsulation. Keep the override protected unless you deliberately want it public.

4. Constructor order when abstract methods are called from the base constructor

typescript
abstract class Base { protected abstract init(): void; constructor() { this.init(); } // Calls subclass override during base construction } class Derived extends Base { protected value = 42; protected init(): void { this.value *= 2; } } const d = new Derived(); console.log((d as any).value); // 84

This surprises developers who assume subclass field initializers run before the base constructor. They do not. init() fires inside the base constructor() call, before value = 42 gets a chance to assign. I have seen this break logic inside a NestJS base controller where the team expected a default property to be set before init ran. The fix is to avoid calling abstract methods inside constructors whenever you can.

5. Using abstract classes in purely functional contexts

In React code built around hooks, interfaces are lighter and compose better. Adding an abstract class where an interface would do brings extra weight without any real benefit.

Real-world usage

  • NestJS: controllers and services extend abstract base classes with shared validation and decorator logic
  • TypeORM: BaseEntity provides concrete save(), remove(), and find() methods that every entity inherits
  • Express: a base middleware class with abstract handle(req, res) forces each route class to define its own handler

Follow-up questions

Q: What does the JavaScript output of an abstract class look like?
A: A plain ES6 class. The abstract keyword and all abstract member declarations are stripped. Only concrete methods remain. The structural check happens entirely at compile time.

Q: Can abstract properties exist in TypeScript?
A: Yes. abstract color: string; in a base class forces the subclass to assign that property, either as a direct field or through a getter.

Q: Is there a runtime performance difference between abstract classes and interfaces?
A: No. Interfaces disappear at compile time. Abstract classes become regular JS classes. Both have identical runtime cost.

Q: Can you declare a private abstract method?
A: No, TypeScript throws an error. Abstract methods must be protected or public so subclasses can override them.

Q: Why can a class use extends on an abstract class but not implements it?
A: TypeScript uses structural typing for interfaces and a more nominal approach for classes. With implements, TypeScript checks only the shape: method signatures and property names. An abstract class can carry access modifiers, constructors, and concrete methods that implements would silently ignore. Using extends gives you the full chain: constructor inheritance, concrete method inheritance, and compile-time checks on abstract members. If you could implements an abstract class, you could skip all the shared logic it provides, which defeats the purpose of having it.

Examples

Basic: shape hierarchy

typescript
abstract class Shape { // Concrete: every shape can describe itself using the same format describe(): string { return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`; } // Abstract: each shape calculates differently abstract area(): number; abstract perimeter(): number; } class Circle extends Shape { constructor(private radius: number) { super(); } area(): number { return Math.PI * this.radius ** 2; } perimeter(): number { return 2 * Math.PI * this.radius; } } class Rectangle extends Shape { constructor(private width: number, private height: number) { super(); } area(): number { return this.width * this.height; } perimeter(): number { return 2 * (this.width + this.height); } } const circle = new Circle(5); circle.describe(); // Area: 78.54, Perimeter: 31.42 const rect = new Rectangle(4, 6); rect.describe(); // Area: 24.00, Perimeter: 20.00

describe() is written once and works for every shape. Each subclass only fills in the math it owns.

Intermediate: API route handler base (NestJS-inspired)

typescript
interface Request { id?: number; body?: unknown; } interface ResponseData { success: boolean; data?: unknown; error?: string; } abstract class ValidatedRoute { // Shared: validation logic reused by every endpoint protected validate(req: Request): boolean { return typeof req.id === 'number' && req.id > 0; } // Abstract: each route defines its own handling abstract handle(req: Request): ResponseData; } class UserRoute extends ValidatedRoute { handle(req: Request): ResponseData { if (!this.validate(req)) { return { success: false, error: 'Invalid ID' }; } return { success: true, data: { id: req.id } }; } } const route = new UserRoute(); route.handle({ id: 1 }); // { success: true, data: { id: 1 } } route.handle({ id: -1 }); // { success: false, error: 'Invalid ID' }

This pattern shows up in Express and NestJS middleware. Validation logic lives once in the base. Each route class only defines what happens after validation passes.

Advanced: repository pattern with generics

typescript
abstract class BaseRepository<T extends { id: string }> { protected abstract collection: string; // Concrete: shared fetch logic reused by all repositories async findById(id: string): Promise<T | null> { const response = await fetch(`/api/${this.collection}/${id}`); if (!response.ok) return null; return response.json(); } // Abstract: each repo defines its own write operations abstract save(entity: T): Promise<T>; abstract delete(id: string): Promise<void>; } interface User { id: string; name: string; } class UserRepository extends BaseRepository<User> { protected collection = 'users'; async save(user: User): Promise<User> { const response = await fetch(`/api/${this.collection}`, { method: 'POST', body: JSON.stringify(user), }); return response.json(); } async delete(id: string): Promise<void> { await fetch(`/api/${this.collection}/${id}`, { method: 'DELETE' }); } }

The generic constraint T extends { id: string } guarantees every entity has an id. findById works for every repository that extends this base. Only the write operations differ per entity type.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?