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 usingthisin 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
extendsfor class inheritance when sharing implementation - Call
super()before accessingthisin child constructors - Use the
overridekeyword 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.