Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Абстрактні класи в TypeScript». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Абстрактний клас** у TypeScript - це клас, який не можна інстанціювати напряму. Він поєднує конкретні методи зі спільною реалізацією та абстрактні методи, які підкласи зобов'язані реалізувати. ```typescript abstract class Shape { abstract area(): number; describe(): string { return `Area: ${this.area().toFixed(2)}`; } } class Circle extends Shape { area(): number { return Math.PI * 5 ** 2; } // 78.54 } ``` **Ключове:** дає спільну реалізацію разом з контрактом, на відміну від interface, який надає тільки контракт.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Абстрактний клас** у 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` працює для будь-якого репозиторію, що розширює цю базу. Тільки операції запису різняться залежно від типу сутності.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.