📘 TypeScript

ООП: Наследование в TypeScript: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты работаешь на кондитерской фабрике. Сначала ты создал рецепт обычного Пирога: у него есть тесто, начинка и способ выпекания. Этот рецепт — твой базовый класс. Теперь тебе нужно сделать Яблочный пирог. Вместо того чтобы писать рецепт с нуля, ты берёшь рецепт обычного пирога и просто добавляешь в него пункт: «положить яблоки». То же самое с Мясным пирогом — ты берёшь базу и меняешь начинку.

Это и есть наследование в программировании. Ты создаёшь один общий класс (родительский), а затем на его основе строишь новые классы (дочерние), которые автоматически получают все свойства и методы родителя. Дочерний класс может:

  • использовать всё, что есть у родителя, без переписывания кода;
  • добавлять свои собственные свойства и методы;
  • изменять (переопределять) поведение родительских методов.

Зачем это нужно? Представь, что у тебя в программе есть 10 разных типов пользователей: Админ, Модератор, Редактор, Подписчик. Если не использовать наследование, тебе придётся 10 раз писать код для имени, email и пароля. А если ты захочешь добавить поле «аватарка», тебе придётся лезть во все 10 классов. Наследование решает эту проблему: ты пишешь общий код один раз в классе User, а все остальные просто наследуют его.

💡 Наследование — это отношение «ЯВЛЯЕТСЯ» (is-a). Яблочный пирог ЯВЛЯЕТСЯ пирогом. Админ ЯВЛЯЕТСЯ пользователем. Собака ЯВЛЯЕТСЯ животным. Если этой связи нет — наследование использовать нельзя.

Теория: как это работает изнутри

Когда ты создаёшь дочерний класс, TypeScript (и JavaScript под капотом) делает следующее:

  1. Создаётся цепочка прототипов. Дочерний класс получает ссылку на родительский класс через специальное свойство __proto__ (или [[Prototype]]). Когда ты вызываешь метод у объекта дочернего класса, JavaScript сначала ищет этот метод в самом объекте. Если не находит — поднимается по цепочке прототипов к родителю.
  2. Конструктор родителя вызывается через super(). Если у родителя есть логика в конструкторе (например, установка имени), дочерний класс должен её запустить. Для этого используется ключевое слово super. Это как сказать: «Сначала сделай всё, что делает родитель при рождении, а потом добавь мои особенности».
  3. Методы не копируются, а ссылаются. Когда ты наследуешь метод, он не копируется в дочерний класс. Дочерний класс просто знает, что может им воспользоваться. Это экономит память.
  4. Переопределение методов. Если ты объявляешь в дочернем классе метод с тем же именем, что и у родителя, он «перекрывает» родительский. При этом ты всё ещё можешь вызвать родительскую версию через super.имяМетода().

В TypeScript добавляется ещё один важный слой — модификаторы доступа. Они контролируют, что именно дочерний класс может видеть и использовать:

Модификатор Видимость для дочернего класса Видимость снаружи
public Доступно (можно использовать и переопределять) Доступно
protected Доступно (можно использовать и переопределять) НЕ доступно
private НЕ доступно НЕ доступно
⚠️ Важно: private в TypeScript — это строгое ограничение на уровне компиляции. В скомпилированном JavaScript его нет, но TypeScript не даст тебе обратиться к приватному полю из дочернего класса.

Синтаксис в TypeScript: все формы и варианты

Давай разберём все возможные способы использования наследования в TypeScript. Начнём с самой простой формы и будем усложнять.

1. Базовое наследование (extends)

Ключевое слово extends — это основа. Оно говорит: «Этот класс является расширением другого класса».

// Родительский класс
class Animal {
    name: string;

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

    makeSound(): void {
        console.log("Издаёт звук");
    }
}

// Дочерний класс — наследует всё от Animal
class Dog extends Animal {
    // Добавляем новый метод, которого нет у родителя
    wagTail(): void {
        console.log(`${this.name} виляет хвостом`);
    }
}

const myDog = new Dog("Бобик");
myDog.makeSound(); // "Издаёт звук" — унаследовано
myDog.wagTail();   // "Бобик виляет хвостом" — собственный метод

2. Вызов родительского конструктора через super()

Если у родителя есть конструктор с параметрами, дочерний класс обязан его вызвать через super(). Это первая строка в конструкторе дочернего класса.

class Vehicle {
    brand: string;
    year: number;

    constructor(brand: string, year: number) {
        this.brand = brand;
        this.year = year;
    }

    getInfo(): string {
        return `${this.brand}, ${this.year} года выпуска`;
    }
}

class Car extends Vehicle {
    doorsCount: number;

    constructor(brand: string, year: number, doorsCount: number) {
        // Сначала вызываем конструктор родителя
        super(brand, year);
        // Потом добавляем свои свойства
        this.doorsCount = doorsCount;
    }

    getCarInfo(): string {
        // Можно использовать методы родителя
        return `${this.getInfo()}, количество дверей: ${this.doorsCount}`;
    }
}

const myCar = new Car("Toyota", 2020, 4);
console.log(myCar.getCarInfo()); // "Toyota, 2020 года выпуска, количество дверей: 4"

3. Переопределение методов (override)

Дочерний класс может полностью заменить метод родителя. В TypeScript для этого используется ключевое слово override (начиная с версии 4.3). Оно не обязательно, но помогает избежать ошибок.

class Bird {
    fly(): void {
        console.log("Птица летит");
    }
}

class Penguin extends Bird {
    // Переопределяем метод fly
    override fly(): void {
        console.log("Пингвины не летают, они плавают");
    }
}

const penguin = new Penguin();
penguin.fly(); // "Пингвины не летают, они плавают"

4. Вызов родительского метода через super.метод()

Иногда нужно не заменить метод полностью, а дополнить его. Для этого вызываем родительскую версию через super.

class Worker {
    name: string;

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

    work(): void {
        console.log(`${this.name} работает`);
    }
}

class Developer extends Worker {
    language: string;

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

    override work(): void {
        // Сначала делаем то, что делает обычный работник
        super.work();
        // Потом добавляем специфику разработчика
        console.log(`${this.name} пишет код на ${this.language}`);
    }
}

const dev = new Developer("Анна", "TypeScript");
dev.work();
// "Анна работает"
// "Анна пишет код на TypeScript"

5. Защищённые (protected) свойства и методы

Иногда нужно, чтобы свойство было доступно дочерним классам, но скрыто от внешнего мира. Для этого используем protected.

class Account {
    protected balance: number = 0; // Доступно только внутри класса и наследников

    deposit(amount: number): void {
        this.balance += amount;
    }

    public getBalance(): number {
        return this.balance;
    }
}

class SavingsAccount extends Account {
    private interestRate: number = 0.05;

    addInterest(): void {
        // Мы можем обратиться к balance, потому что он protected
        const interest = this.balance * this.interestRate;
        this.balance += interest;
        console.log(`Начислены проценты: ${interest}`);
    }
}

const savings = new SavingsAccount();
savings.deposit(1000);
savings.addInterest(); // "Начислены проценты: 50"
console.log(savings.getBalance()); // 1050
// console.log(savings.balance); // Ошибка! balance — protected

6. Абстрактные классы

Абстрактный класс — это класс, который нельзя создать напрямую. Он служит только для наследования. В нём могут быть абстрактные методы — методы без реализации, которые обязан реализовать дочерний класс.

abstract class Shape {
    abstract getArea(): number; // Абстрактный метод — нет тела

    // Обычный метод — есть реализация
    describe(): void {
        console.log(`Это фигура с площадью ${this.getArea()}`);
    }
}

class Circle extends Shape {
    radius: number;

    constructor(radius: number) {
        super();
        this.radius = radius;
    }

    // Обязаны реализовать абстрактный метод
    override getArea(): number {
        return Math.PI * this.radius ** 2;
    }
}

// const shape = new Shape(); // Ошибка! Нельзя создать экземпляр абстрактного класса
const circle = new Circle(5);
circle.describe(); // "Это фигура с площадью 78.53981633974483"

7. Множественное наследование через интерфейсы

TypeScript (как и JavaScript) не поддерживает множественное наследование классов (когда один класс наследует от двух родителей). Но эту проблему решают интерфейсы. Класс может реализовать несколько интерфейсов.

interface Flyable {
    fly(): void;
}

interface Swimmable {
    swim(): void;
}

// Класс может реализовать несколько интерфейсов
class Duck implements Flyable, Swimmable {
    fly(): void {
        console.log("Утка летит");
    }

    swim(): void {
        console.log("Утка плывёт");
    }
}

const duck = new Duck();
duck.fly();  // "Утка летит"
duck.swim(); // "Утка плывёт"

Примеры кода — от простого к сложному

Пример 1: Простейшее наследование

// Базовый класс "Транспорт"
class Transport {
    speed: number;

    constructor(speed: number) {
        this.speed = speed;
    }

    move(): void {
        console.log(`Движется со скоростью ${this.speed} км/ч`);
    }
}

// Дочерний класс "Велосипед"
class Bicycle extends Transport {
    // Ничего не добавляем, просто наследуем
}

const bike = new Bicycle(25);
bike.move(); // "Движется со скоростью 25 км/ч"

Пример 2: Добавление свойств и super()

class Smartphone {
    brand: string;
    protected batteryLevel: number = 100;

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

    charge(): void {
        this.batteryLevel = 100;
        console.log("Батарея заряжена");
    }
}

class IPhone extends Smartphone {
    iosVersion: string;

    constructor(brand: string, iosVersion: string) {
        super(brand); // Обязательно вызываем родительский конструктор
        this.iosVersion = iosVersion;
    }

    // Новый метод
    updateIOS(): void {
        console.log(`Обновляем ${this.brand} до версии ${this.iosVersion}`);
    }
}

const myPhone = new IPhone("Apple", "17.0");
myPhone.charge();    // "Батарея заряжена" — унаследовано
myPhone.updateIOS(); // "Обновляем Apple до версии 17.0"

Пример 3: Переопределение метода с super

class Chef {
    cook(): void {
        console.log("Готовит обычное блюдо");
    }
}

class PizzaChef extends Chef {
    override cook(): void {
        super.cook(); // Сначала делаем базовое действие
        console.log("Добавляет сыр и томатный соус");
        console.log("Запекает в печи");
    }
}

const pizzaChef = new PizzaChef();
pizzaChef.cook();
// "Готовит обычное блюдо"
// "Добавляет сыр и томатный соус"
// "Запекает в печи"

Пример 4: Защищённые свойства

class Employee {
    public name: string;
    protected salary: number; // Доступно наследникам, но не снаружи
    private passportData: string; // Доступно только внутри класса

    constructor(name: string, salary: number, passport: string) {
        this.name = name;
        this.salary = salary;
        this.passportData = passport;
    }

    getSalaryInfo(): string {
        return `Зарплата: ${this.salary}`;
    }
}

class Manager extends Employee {
    private teamSize: number;

    constructor(name: string, salary: number, passport: string, teamSize: number) {
        super(name, salary, passport);
        this.teamSize = teamSize;
    }

    getFullInfo(): string {
        // Можно обратиться к salary, так как он protected
        return `${this.name}, зарплата: ${this.salary}, команда: ${this.teamSize} человек`;
        // this.passportData — ошибка! private не доступен наследникам
    }
}

const manager = new Manager("Иван", 150000, "1234 567890", 5);
console.log(manager.getFullInfo()); // "Иван, зарплата: 150000, команда: 5 человек"
// console.log(manager.salary); // Ошибка! protected
// console.log(manager.passportData); // Ошибка! private

Пример 5: Абстрактный класс с несколькими наследниками

abstract class Database {
    abstract connect(): void;
    abstract disconnect(): void;

    // Общий метод для всех баз данных
    executeQuery(query: string): void {
        this.connect();
        console.log(`Выполняем запрос: ${query}`);
        this.disconnect();
    }
}

class MySQLDatabase extends Database {
    override connect(): void {
        console.log("Подключаемся к MySQL...");
    }

    override disconnect(): void {
        console.log("Отключаемся от MySQL...");
    }
}

class PostgreSQLDatabase extends Database {
    override connect(): void {
        console.log("Подключаемся к PostgreSQL...");
    }

    override disconnect(): void {
        console.log("Отключаемся от PostgreSQL...");
    }
}

const mysql = new MySQLDatabase();
mysql.executeQuery("SELECT * FROM users");
// "Подключаемся к MySQL..."
// "Выполняем запрос: SELECT * FROM users"
// "Отключаемся от MySQL..."

const postgres = new PostgreSQLDatabase();
postgres.executeQuery("SELECT * FROM orders");
// "Подключаемся к PostgreSQL..."
// "Выполняем запрос: SELECT * FROM orders"
// "Отключаемся от PostgreSQL..."

Пример 6: Цепочка наследования (многоуровневое)

class LivingBeing {
    isAlive: boolean = true;

    breathe(): void {
        console.log("Дышит");
    }
}

class Animal2 extends LivingBeing {
    name: string;

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

    eat(): void {
        console.log(`${this.name} ест`);
    }
}

class Mammal extends Animal2 {
    hasFur: boolean = true;

    feedMilk(): void {
        console.log(`${this.name} кормит молоком`);
    }
}

class Dog2 extends Mammal {
    breed: string;

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

    bark(): void {
        console.log(`${this.name} лает`);
    }
}

const myDog2 = new Dog2("Рекс", "Овчарка");
myDog2.breathe();  // "Дышит" — от LivingBeing
myDog2.eat();      // "Рекс ест" — от Animal2
myDog2.feedMilk(); // "Рекс кормит молоком" — от Mammal
myDog2.bark();     // "Рекс лает" — собственный

Пример 7: Статические методы и наследование

class Config {
    static defaultTimeout: number = 5000;

    static getDefaultConfig(): object {
        return { timeout: this.defaultTimeout };
    }
}

class ExtendedConfig extends Config {
    static maxRetries: number = 3;

    // Переопределяем статический метод
    static getDefaultConfig(): object {
        // Вызываем родительский статический метод
        const baseConfig = super.getDefaultConfig();
        return { ...baseConfig, maxRetries: this.maxRetries };
    }
}

console.log(Config.getDefaultConfig());       // { timeout: 5000 }
console.log(ExtendedConfig.getDefaultConfig()); // { timeout: 5000, maxRetries: 3 }

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python или планируешь изучать его, вот ключевые различия в реализации наследования:

Характеристика TypeScript Python
Ключевое слово для наследования extends Класс указывается в скобках: class Dog(Animal):
Вызов родительского конструктора super() super().__init__() или Animal.__init__(self)
Множественное наследование классов НЕ поддерживается (только через интерфейсы) Поддерживается: class C(A, B):
Модификаторы доступа public, protected, private Нет ключевых слов. Используются соглашения: _protected (одно подчёркивание) и __private (два подчёркивания)
Абстрактные классы Ключевое слово abstract Модуль abc: from abc import ABC, abstractmethod
Переопределение методов Опционально override Просто объявляешь метод с тем же именем
Типизация Статическая (проверяется на этапе компиляции) Динамическая (проверяется во время выполнения)
Проверка типа instanceof isinstance()

Пример на Python для сравнения:

// TypeScript
class Animal {
    constructor(public name: string) {}
    speak(): void {
        console.log(`${this.name} издаёт звук`);
    }
}

class Cat extends Animal {
    constructor(name: string) {
        super(name);
    }
    override speak(): void {
        console.log(`${this.name} мяукает`);
    }
}
# Python
class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def speak(self):
        print(f"{self.name} издаёт звук")

class Cat(Animal):
    def __init__(self, name: str):
        super().__init__(name)
    
    def speak(self):
        print(f"{self.name} мяукает")
💡 Главное отличие: Python позволяет наследовать от нескольких классов сразу, но это часто приводит к проблемам (ромбовидное наследование). TypeScript запрещает это на уровне языка, заставляя использовать интерфейсы — это считается более безопасным подходом.

Типичные ошибки и подводные камни

Ошибка 1: Забыть вызвать super()

Если у родительского класса есть конструктор, дочерний класс обязан вызвать super(). Иначе TypeScript выдаст ошибку компиляции.

class Parent {
    constructor(value: number) {
        // ...
    }
}

class Child extends Parent {
    constructor() {
        // Ошибка! Нужно super(value)
    }
}

Ошибка 2: Вызов super() не в первой строке

super() должен быть вызван до того, как ты обратишься к this в дочернем конструкторе.

class Child extends Parent {
    constructor() {
        this.someProperty = 5; // Ошибка! Нельзя использовать this до super()
        super(10);
    }
}

Ошибка 3: Путаница между public, protected и private

Новички часто делают все свойства public, а потом удивляются, что внешний код может их менять. Или наоборот, ставят private там, где нужно protected, и наследники не могут получить доступ.

class Base {
    private secret: string = "секрет";
}

class Derived extends Base {
    revealSecret(): void {
        // Ошибка! secret — private, недоступен наследникам
        console.log(this.secret);
    }
}

Ошибка 4: Нарушение принципа подстановки Лисков (LSP)

Это правило гласит: объекты дочернего класса должны заменять объекты родительского класса без изменения корректности программы. Если ты переопределяешь метод так, что он выбрасывает исключение или требует других параметров — это ошибка проектирования.

class Rectangle {
    constructor(public width: number, public height: number) {}
    
    setWidth(width: number): void {
        this.width = width;
    }
    
    setHeight(height: number): void {
        this.height = height;
    }
    
    getArea(): number {
        return this.width * this.height;
    }
}

// Нарушение LSP: квадрат не должен наследовать прямоугольник
class Square extends Rectangle {
    constructor(size: number) {
        super(size, size);
    }
    
    // Ломает логику родителя
    override setWidth(width: number): void {
        this.width = width;
        this.height = width; // Меняет и высоту тоже!
    }
}

Ошибка 5: Слишком глубокая иерархия наследования

Если у тебя класс наследует от класса, который наследует от класса, который наследует... — это «спагетти-код». Сложно понять, откуда берутся методы, и любое изменение в корне может сломать всё.

⚠️ Золотое правило: не более 3 уровней наследования. Если нужно больше — используй композицию (включение объектов) вместо наследования.

Ошибка 6: Использование наследования для переиспользования кода, когда нет отношения «является»

Класс Car не должен наследовать класс Engine, потому что машина не является двигателем. У машины есть двигатель (композиция).

// ПЛОХО: наследование
class Engine {
    start(): void { console.log("Двигатель запущен"); }
}
class Car extends Engine { } // Машина — это двигатель? Нет!

// ХОРОШО: композиция
class Engine2 {
    start(): void { console.log("Двигатель запущен"); }
}
class Car2 {
    constructor(private engine: Engine2) {}
    start(): void { this.engine.start(); }
}

Лучшие практики и советы профессионалов

  1. Используй наследование только для отношения «является». Если ты сомневаешься, спроси себя: «Могу ли я сказать, что дочерний класс — это разновидность родительского?» Если нет — используй композицию.
  2. Делай классы максимально плоскими. Глубокая иерархия — зло. Старайся, чтобы было не больше 2-3 уровней.
  3. Используй protected вместо public для внутренних свойств. Это защитит данные от случайного изменения извне, но оставит доступ наследникам.
  4. Всегда используй override при переопределении методов. Это поможет TypeScript найти ошибку, если ты случайно неправильно написал имя метода.
  5. Предпочитай интерфейсы абстрактным классам, если нет общей реализации. Интерфейсы более гибкие и позволяют реализовать несколько контрактов.
  6. Не бойся композиции. Часто лучше передать объект в конструктор, чем наследовать от него. Композиция даёт больше гибкости.
  7. Документируй, какие методы можно переопределять. Если ты пишешь библиотеку, помечай методы, которые предназначены для переопределения, комментарием или используй абстрактные классы.
  8. Тестируй наследование. Напиши тесты, которые проверяют, что дочерний класс корректно работает везде, где ожидается родительский.
💡 Совет от профессионалов: «Предпочитай композицию наследованию» (GoF, "Банда четырёх"). Это не значит, что наследование плохое — оно просто должно использоваться осознанно и там, где это действительно нужно.

Вопросы для самопроверки

  1. Вопрос: Что такое наследование в ООП? Приведи пример из реальной жизни.

    Ответ: Наследование — это механизм, позволяющий создать новый класс на основе существующего. Дочерний класс получает все свойства и методы родителя. Пример: класс «Пирог» — родитель, класс «Яблочный пирог» — дочерний. Яблочный пирог — это пирог, но с дополнительными свойствами.

  2. Вопрос: Какое ключевое слово используется для наследования в TypeScript?

    Ответ: Ключевое слово extends. Пример: class Dog extends Animal {}.

  3. Вопрос: Зачем нужен вызов super() в конструкторе дочернего класса?

    Ответ: super() вызывает конструктор родительского класса. Это необходимо, чтобы инициализировать свойства, определённые в родителе. Без этого вызова TypeScript выдаст ошибку, если у родителя есть конструктор.

  4. Вопрос: В чём разница между public, protected и private?

    Ответ: public — доступно всем. protected — доступно внутри класса и его наследников. private — доступно только внутри класса, где объявлено. Наследники не видят private члены.

  5. Вопрос: Что такое переопределение метода? Как вызвать родительскую версию метода?

    Ответ: Переопределение — это создание в дочернем классе метода с тем же именем, что и у родителя. Чтобы вызвать родительскую версию, используется super.имяМетода().

  6. Вопрос: Что такое абстрактный класс? Можно ли создать его экземпляр?

    Ответ: Абстрактный класс — это класс, который служит только для наследования. Он помечается ключевым словом abstract. Создать его экземпляр напрямую нельзя. Он может содержать абстрактные методы (без реализации), которые обязаны реализовать наследники.

  7. Вопрос: Поддерживает ли TypeScript множественное наследование классов?

    Ответ: Нет, TypeScript не поддерживает множественное наследование классов (один класс не может наследовать от двух классов). Вместо этого используются интерфейсы: класс может реализовать несколько интерфейсов через ключевое слово implements.

  8. Вопрос: В чём разница между наследованием и композицией?