Skip to main content

Абстрактні класи в TypeScript

Абстрактний клас у TypeScript - це клас, який не можна інстанціювати напряму. Він слугує спільною базою для підкласів і поєднує конкретні методи (реалізація, яку всі успадковують) з абстрактними методами (обов'язкові перевизначення). Контракт і спільний код в одному місці.

Теорія

TL;DR

  • Аналогія з кресленням: визначає обов'язкові кімнати (абстрактні методи) і готові стіни (конкретні методи), але жити в кресленні не можна
  • Головна різниця з interface: абстрактний клас дає часткову реалізацію плюс контракт; interface - тільки контракт, без коду
  • TypeScript перевіряє абстрактні члени під час компіляції; в рантаймі V8 бачить звичайний ES6-клас
  • Правило вибору: використовуй, коли 2+ підкласи ділять реальну логіку; interface - коли потрібна тільки форма

Швидкий приклад

typescript
abstract class Animal { // Конкретний: спільний для всіх підкласів автоматично move(): string { return `${this.sound()} while moving`; } // Абстрактний: кожен підклас реалізує сам abstract sound(): string; } class Dog extends Animal { sound(): string { return 'Woof!'; } } const dog = new Dog(); dog.move(); // 'Woof! while moving' // new Animal(); // Помилка: Cannot create an instance of an abstract class

move() написаний один раз у базовому класі. Кожен підклас отримує його без зайвого коду. sound() змушує кожен підклас визначити власну поведінку. Ось і вся ідея.

Абстрактний клас vs interface

Обидва описують форму. Тільки абстрактний клас несе реальний код.

interface оголошує, як виглядає тип: сигнатури методів, імена властивостей, типи повернення. Нічого не виконується в рантаймі, бо TypeScript стирає інтерфейси при компіляції повністю. Абстрактний клас, навпаки, компілюється в JavaScript. Його конкретні методи стають справжніми методами класу. Абстрактні члени перетворюються на обов'язкові перевизначення, які TypeScript перевіряє перед генерацією JS.

Практичний поділ: якщо два або більше класи потребують спільної логіки, підходить абстрактний клас. Якщо потрібен лише структурний контракт (для параметрів функцій, типів повернення або слабкого зв'язування модулів), вистачить interface. У більшості кодових баз використовуються обидва разом.

ОсобливістьАбстрактний класInterface
Конкретна реалізаціяТакНі
КонструкторТакНі
Модифікатори доступуТак (public, protected, private)Ні
Властивості зі значеннямиТакНі
Множинне успадкуванняНі (один extends)Так (кілька implements)
Існує в рантайміТак (JS-клас)Ні (стирається при компіляції)

Коли використовувати

  • Спільна логіка в підкласах: базовий Repository з конкретним findById, який успадковують усі репозиторії
  • Template method pattern (шаблонний метод): визначаєш скелет алгоритму в базі, підкласи заповнюють конкретні кроки
  • Ієрархія зі станом: потрібні властивості, конструктори й методи разом в одній базі
  • Factory patterns: базовий клас для динамічного створення екземплярів підкласів

Пропускай абстрактні класи, коли потрібен лише контракт (достатньо interface), коли клас використовується тільки в одному місці, або коли пишеш функціональний React-код, де interface вписується природніше.

Як TypeScript компілює абстрактні класи

Компілятор (tsc) прибирає ключове слово abstract і генерує звичайний ES6-клас. Конкретні методи стають звичайними методами класу. Оголошення абстрактних методів зникають повністю. Компілятор перевіряє, що кожен підклас реалізував усі абстрактні члени, перед тим як генерувати JS-вихід.

В рантаймі V8 або Node нічого особливого не бачить. Вся перевірка - тільки TypeScript, у скомпільованому коді її немає.

Типові помилки

1. Спроба інстанціювати абстрактний клас напряму

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

Виправлення: розшир клас і реалізуй абстрактний метод, потім інстанціюй підклас.

2. Забув реалізувати всі абстрактні члени

typescript
abstract class A { abstract greet(): string; abstract farewell(): string; } class B extends A { greet() { return 'Hi'; } // Відсутній farewell() -> TS2515: Non-abstract class 'B' does not implement... }

Виправлення: реалізуй кожен абстрактний член, або познач сам B як abstract.

3. Перетворення protected-абстракту на public без потреби

typescript
abstract class Base { protected abstract init(): void; } class Derived extends Base { public init(): void { console.log('ready'); } // TypeScript дозволяє розширення до public, але... }

Це компілюється. Але виставляє внутрішній хук як частину публічного API. Користувачі Derived можуть викликати init() напряму, що ламає інкапсуляцію. Тримай перевизначення protected, якщо ти навмисно не хочеш публічного доступу.

4. Порядок конструктора при виклику абстрактних методів з базового конструктора

typescript
abstract class Base { protected abstract init(): void; constructor() { this.init(); } // Викликає перевизначення підкласу під час конструювання базового } class Derived extends Base { protected value = 42; protected init(): void { this.value *= 2; } } const d = new Derived(); console.log((d as any).value); // 84

Це дивує тих, хто вважає, що ініціалізатори полів підкласу виконуються перед конструктором базового. Вони не виконуються. init() спрацьовує всередині виклику базового constructor(). Я бачив, як цей патерн ламав логіку в базовому контролері NestJS - команда очікувала, що властивість за замовчуванням вже встановлена під час виклику init. Уникай виклику абстрактних методів у конструкторах.

5. Використання абстрактних класів у чисто функціональному коді

У React-коді з хуками interface зазвичай легший і краще компонується. Абстрактний клас там додає вагу без реальної користі.

Використання в реальних проєктах

  • NestJS: контролери і сервіси розширюють абстрактні базові класи зі спільною валідацією та декораторами
  • TypeORM: BaseEntity надає конкретні save(), remove(), find(), які успадковує кожна сутність
  • Express: базовий клас middleware з abstract handle(req, res) змушує кожен клас маршруту визначити власну логіку

Питання на співбесіді

Q: Як виглядає JS-код після компіляції абстрактного класу?
A: Звичайний ES6-клас. Ключове слово abstract і всі абстрактні оголошення прибираються. Залишаються тільки конкретні методи. Перевірка відбувається виключно під час компіляції.

Q: Чи можуть існувати абстрактні властивості в TypeScript?
A: Так. abstract color: string; у базовому класі зобов'язує підклас присвоїти цю властивість - як пряме поле або через getter.

Q: Чи є різниця в продуктивності між абстрактними класами і interface?
A: Ніякої. Interface зникає при компіляції. Абстрактний клас стає звичайним JS-класом. Рантайм-вартість однакова.

Q: Чи можна оголосити private abstract метод?
A: Ні, TypeScript повертає помилку. Абстрактні методи мають бути protected або public, щоб підкласи могли їх перевизначити.

Q: Чому клас може extends абстрактний клас, але не implements його?
A: TypeScript використовує структурну типізацію для interface і більш номінальний підхід для class. При implements TypeScript перевіряє лише форму: сигнатури методів і імена властивостей. Абстрактний клас може мати модифікатори доступу, конструктори і конкретні методи, які implements просто ігнорував би. extends дає повний ланцюг: успадкування конструктора, успадкування конкретних методів і перевірку абстрактних членів під час компіляції. Якби можна було implements абстрактний клас, можна було б пропустити всю спільну логіку, яку він надає.

Приклади

Базовий: ієрархія фігур

typescript
abstract class Shape { // Конкретний: кожна фігура може описати себе в однаковому форматі describe(): string { return `Area: ${this.area().toFixed(2)}, Perimeter: ${this.perimeter().toFixed(2)}`; } // Абстрактні: кожна фігура рахує по-своєму 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() написаний один раз і працює для кожної фігури. Кожен підклас заповнює тільки своє обчислення.

Проміжний: базовий обробник API-маршрутів

typescript
interface Request { id?: number; body?: unknown; } interface ResponseData { success: boolean; data?: unknown; error?: string; } abstract class ValidatedRoute { // Спільна валідація для кожного маршруту protected validate(req: Request): boolean { return typeof req.id === 'number' && req.id > 0; } // Абстрактний: кожен маршрут визначає власну логіку 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' }

Цей патерн зустрічається в Express і NestJS. Логіка валідації живе один раз у базовому класі. Кожен клас маршруту визначає лише те, що робити після успішної валідації.

Просунутий: патерн репозиторію (repository pattern) з дженериками

typescript
abstract class BaseRepository<T extends { id: string }> { protected abstract collection: string; // Конкретний: спільна логіка fetch для всіх репозиторіїв async findById(id: string): Promise<T | null> { const response = await fetch(`/api/${this.collection}/${id}`); if (!response.ok) return null; return response.json(); } // Абстрактні: кожен репозиторій визначає своє збереження і видалення 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' }); } }

Обмеження дженерика T extends { id: string } гарантує, що кожна сутність має id. findById працює для будь-якого репозиторію, що розширює цю базу. Тільки операції запису різняться залежно від типу сутності.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?