Skip to main content

TypeScript Inheritance

You're refactoring a legacy codebase where you notice the same validation logic copy-pasted across five different classes. Each time requirements change, you need to update all five locations. This is the type of problem TypeScript inheritance solves. In this guide, we'll cover everything from basic class inheritance to abstract classes and when composition might be the better choice.

Understanding Class Inheritance in TypeScript

Class inheritance in TypeScript lets you build new classes on top of existing ones, automatically gaining their properties and methods. You use the extends keyword to establish this parent-child relationship.

class Animal {
move(distance: number) {
console.log(`Animal moved ${distance}m.`);
}
}

class Dog extends Animal {
bark() {
console.log('Woof! Woof!');
}
}

const dog = new Dog();
dog.bark(); // Woof! Woof!
dog.move(10); // Animal moved 10m.

The Dog class inherits move() from Animal without writing any extra code. This is inheritance at its simplest.

Managing Constructors in TypeScript Inheritance

When your child class needs a constructor, you must call super() before accessing this. This ensures the parent class initializes properly before you add your own initialization logic.

class Animal {
name: string;

constructor(name: string) {
this.name = name;
}
}

class Dog extends Animal {
breed: string;

constructor(name: string, breed: string) {
super(name); // Must call super before using 'this'
this.breed = breed;
}
}

const myDog = new Dog('Max', 'Golden Retriever');

Constructor Parameter Properties

TypeScript offers a shorthand that combines parameter declaration and property assignment:

class Animal {
constructor(public name: string, protected age: number) {
// Properties automatically created and assigned
}
}

class Dog extends Animal {
constructor(name: string, age: number, public breed: string) {
super(name, age);
// 'breed' is automatically assigned, no extra code needed
}
}

Key constructor rules:

  • Call super() before using this in a child class
  • Pass required parent parameters to super()
  • Child constructors can have additional parameters
  • If you don't define a constructor, TypeScript creates one that calls super() automatically

For more details on constructor patterns, check out the guide on TypeScript constructor.

Access Modifiers and Protected Members

Access modifiers control how members of your TypeScript class can be accessed. TypeScript has three main access modifiers: public, private, and protected.

class BankAccount {
public accountName: string;
private balance: number;
protected accountNumber: number;

constructor(accountName: string, balance: number, accountNumber: number) {
this.accountName = accountName;
this.balance = balance;
this.accountNumber = accountNumber;
}

getBalance() {
return this.balance; // Accessible within the class
}
}

class SavingsAccount extends BankAccount {
calculateInterest(rate: number) {
// Can access protected member from parent
return this.accountNumber * rate;
// Cannot access this.balance - it's private to BankAccount
}
}

const account = new SavingsAccount('John Doe', 1000, 123456);
console.log(account.accountName); // John Doe
// console.log(account.balance); // Error: Property 'balance' is private

When you use TypeScript extends, protected members become available to child classes, while private members remain inaccessible. This control helps maintain encapsulation while enabling inheritance.

Overriding Methods in TypeScript

Method overriding lets you replace a parent class implementation with one that better suits your child class. You simply define a method with the same name and signature.

class Printer {
print() {
console.log('Printing document...');
}
}

class ColorPrinter extends Printer {
print() {
console.log('Printing document in color...');
}
}

const colorPrinter = new ColorPrinter();
colorPrinter.print(); // Printing document in color...

Using the override Keyword

TypeScript 4.3 introduced the override keyword to explicitly mark overridden methods. This catches errors if the parent method signature changes:

class Printer {
print(copies: number = 1) {
console.log(`Printing ${copies} copies...`);
}
}

class ColorPrinter extends Printer {
override print(copies: number = 1) {
console.log(`Printing ${copies} color copies...`);
}
}

If you enable noImplicitOverride in your TypeScript config, you'll get compile-time errors when you forget the override keyword or when the parent method no longer exists.

Calling Parent Methods with super

Sometimes you want to extend rather than replace parent behavior:

class BaseLogger {
log(message: string) {
console.log(`[BASE]: ${message}`);
}
}

class AdvancedLogger extends BaseLogger {
override log(message: string) {
super.log(message); // Call parent implementation first
console.log(`[ADVANCED]: ${message}`);
}
}

const logger = new AdvancedLogger();
logger.log('System started');
// Output:
// [BASE]: System started
// [ADVANCED]: System started

Working with Abstract Classes

Abstract classes serve as blueprints that you can't instantiate directly. They define common structure and behavior that child classes must implement. This is perfect when you want to enforce a contract while providing shared functionality.

abstract class Shape {
constructor(public color: string) {}

// Abstract method - child classes must implement
abstract area(): number;

// Concrete method - inherited by all children
describe(): void {
console.log(`A ${this.color} shape with area ${this.area()}`);
}
}

class Circle extends Shape {
constructor(color: string, public radius: number) {
super(color);
}

area(): number {
return Math.PI * (this.radius ** 2);
}
}

class Rectangle extends Shape {
constructor(color: string, public width: number, public height: number) {
super(color);
}

area(): number {
return this.width * this.height;
}
}

const circle = new Circle('red', 5);
circle.describe(); // A red shape with area 78.54...

const rect = new Rectangle('blue', 10, 20);
rect.describe(); // A blue shape with area 200

// const shape = new Shape('green'); // Error: Cannot create an instance of an abstract class

Abstract classes work well when you need to:

  • Define a template that subclasses must follow
  • Share common implementation across related classes
  • Enforce certain methods must be implemented by children
  • Provide default behavior that can be optionally overridden

For a deeper dive into abstract class patterns, see the TypeScript abstract class guide.

Using Interfaces for Inheritance in TypeScript

TypeScript interface inheritance works differently from class inheritance but serves an important role in creating modular, reusable code. Interfaces can extend other interfaces, allowing you to build complex type definitions from simpler ones.

interface Printable {
print(): void;
}

interface Scannable extends Printable {
scan(): void;
}

class Document implements Scannable {
print(): void {
console.log("Printing a document.");
}

scan(): void {
console.log("Scanning a document.");
}
}

extends vs implements: Understanding the Difference

This is one of the most common sources of confusion in TypeScript. Here's the key distinction:

extends is for inheritance. When a class extends another class, it inherits all the implementation (methods and properties). The child gets everything from the parent automatically:

class Vehicle {
drive(): void {
console.log('Driving...');
}
}

class Car extends Vehicle {
// Automatically has drive() method
}

const car = new Car();
car.drive(); // Works without any additional code

implements is for contracts. When a class implements an interface, it promises to provide implementations for all the interface members. You must write all the code yourself:

interface Flyable {
fly(): void;
}

class Airplane implements Flyable {
// Must provide implementation
fly(): void {
console.log('Flying...');
}
}

Multiple Interfaces, Single Class Inheritance

Classes can implement multiple interfaces but can only extend one class:

interface Flyable {
fly(): void;
}

interface Swimmable {
swim(): void;
}

class Duck implements Flyable, Swimmable {
fly() {
console.log('Duck flying...');
}

swim() {
console.log('Duck swimming...');
}
}

const ducky = new Duck();
ducky.fly(); // Duck flying...
ducky.swim(); // Duck swimming...

This approach lets you achieve something similar to multiple inheritance while maintaining type safety and avoiding the diamond problem.

When to Use Inheritance

Inheritance isn't always the right choice. Here's when it makes sense:

Good Use Cases for Inheritance

1. True "is-a" relationships:

class Employee {
constructor(public name: string, public salary: number) {}

calculatePay(): number {
return this.salary;
}
}

class Manager extends Employee {
constructor(name: string, salary: number, public bonus: number) {
super(name, salary);
}

override calculatePay(): number {
return this.salary + this.bonus;
}
}

A Manager truly "is-a" Employee with additional responsibilities.

2. Shared behavior across similar entities:

abstract class HttpError {
constructor(public message: string, public statusCode: number) {}

logError(): void {
console.error(`[${this.statusCode}]: ${this.message}`);
}
}

class NotFoundError extends HttpError {
constructor(resource: string) {
super(`Resource not found: ${resource}`, 404);
}
}

class UnauthorizedError extends HttpError {
constructor() {
super('Unauthorized access', 401);
}
}

All HTTP errors share logging behavior but have different details.

3. Framework requirements: Some frameworks expect class hierarchies (React class components, NestJS services with abstract base classes).

When to Avoid Inheritance

Watch out for these red flags:

  • You're creating deep inheritance chains (more than 2-3 levels)
  • The relationship is more "has-a" than "is-a"
  • You're inheriting just to reuse a few utility methods
  • Child classes ignore or work around parent functionality

Inheritance vs Composition: Making the Right Choice

There's a saying in software design: "Favor composition over inheritance." But what does that mean in practice?

Inheritance creates tight coupling. Changes to the parent affect all children:

class User {
constructor(public name: string) {}

save() {
console.log(`Saving ${this.name} to database...`);
}
}

class AdminUser extends User {
// Tightly coupled to User's save() implementation
// If User changes, AdminUser might break
}

Composition builds objects by combining smaller, focused pieces:

class DatabaseService {
save(entity: any) {
console.log(`Saving to database...`);
}
}

class User {
constructor(
public name: string,
private db: DatabaseService
) {}

save() {
this.db.save(this);
}
}

class AdminUser {
constructor(
public name: string,
private db: DatabaseService,
public permissions: string[]
) {}

save() {
this.db.save(this);
}
}

Now both User and AdminUser can change independently. They share functionality through composition, not inheritance.

Practical Guidelines

Choose inheritance when:

  • You have a clear, stable "is-a" relationship
  • The parent class is designed for inheritance (like abstract classes)
  • You need polymorphism (treating different types uniformly)

Choose composition when:

  • The relationship is "has-a" or "uses-a"
  • You need flexibility to swap implementations
  • You want to combine behaviors from multiple sources
  • The parent class wasn't designed for inheritance

When working with TypeScript inheritance for code reuse, consider how it affects your application's structure. The article on useState-less architecture demonstrates how inheritance patterns can simplify state management in React applications.

Common Pitfalls and How to Avoid Them

Forgetting super() Calls

TypeScript enforces super() in constructors, but it's easy to forget in method overrides:

class Component {
constructor(protected id: string) {}

init(): void {
console.log('Base init');
}
}

class Button extends Component {
constructor(id: string, public label: string) {
super(id); // Required
}

override init(): void {
// Forgetting super.init() might skip important setup
super.init();
console.log('Button init');
}
}

Deep Inheritance Hierarchies

Avoid inheritance chains deeper than 2-3 levels. They become hard to understand and maintain:

// Too deep - hard to track behavior
class Entity extends BaseEntity extends Model extends DataObject {
// Which class does this behavior come from?
}

// Better - flatter, more explicit
class Entity extends BaseEntity {
private model: Model;
private data: DataObject;
}

Protected vs Private Confusion

Remember: protected is accessible in subclasses, private is not:

class Parent {
private secretKey = 'abc123';
protected apiUrl = 'https://api.example.com';
}

class Child extends Parent {
makeRequest() {
// console.log(this.secretKey); // Error: private
console.log(this.apiUrl); // OK: protected
}
}

When working with complex class hierarchies, exploring TypeScript best practices helps avoid common pitfalls. You can also see sophisticated inheritance patterns in action with Convex's API generation.

Putting It All Together

TypeScript inheritance provides a solid foundation for building structured, maintainable applications. You've learned how class inheritance works, when to use abstract classes, and the critical differences between extends and implements.

Remember these key points:

  • Use TypeScript extends for class inheritance when sharing implementation
  • Call super() before accessing this in child constructors
  • Use the override keyword to catch breaking changes in parent classes
  • Choose TypeScript interface extension when defining contracts
  • Favor composition over inheritance unless you have a clear "is-a" relationship
  • Keep inheritance hierarchies shallow (2-3 levels maximum)

Inheritance is a powerful tool, but it's not the only tool. As you grow more comfortable with TypeScript, you'll develop an intuition for when inheritance solves your problem elegantly and when composition offers more flexibility.