Багато команд TypeScript сприймають abstract class та interface як взаємозамінні начинки для одного й того самого сендвіча. Це не так. Вибір не того інструменту призводить до реальних проблем: дублювання бізнес-логіки, жорстких ієрархій класів, які чинять опір будь-якому рефакторингу, та прихованих помилок під час виконання, які ведуть до базового класу, який хтось вважав безпечним. Вихід полягає не в зазубрюванні правил, а в умінні аналізувати реальні вимоги та обирати інструмент, який їм відповідає.

Інтерфейси — це чисті контракти

Інтерфейс — це межа на етапі компіляції. Він описує, як має виглядати об'єкт і що він повинен вміти робити, але не містить жодної реалізації. Після того, як TypeScript компілюється у JavaScript, інтерфейс повністю зникає. Він не залишає ні конструктора, ні ланцюжка прототипів, ні зайвих байтів у вашому бандлі.

Уявіть, що ви створюєте систему сповіщень. Ви визначаєте інтерфейс:

interface Notifier {
  send(message: string): void;
  readonly channel: string;
}

Будь-який клас або навіть звичайний об'єктний літерал, що відповідає цій структурі, є валідним Notifier. EmailNotifier може реалізувати його. Так само, як і SmsNotifier, SlackNotifier або mock-об'єкт, який ви передаєте в юніт-тест. Оскільки інтерфейс не містить коду, кожна реалізація пише власний метод send з нуля. Це саме те, що вам потрібно, коли реалізації не мають нічого спільного, окрім свого публічного інтерфейсу.

Абстрактні класи містять реальний код

Абстрактний клас — це повноцінний клас, якому просто заборонено створювати екземпляри безпосередньо. Він може визначати поля, конкретні методи з логікою та логіку конструктора. Він змушує підкласи заповнювати прогалини за допомогою абстрактних методів, але також надає їм успадковану поведінку, яку їм не потрібно писати самостійно.

Уявіть шар доступу до даних. Кожен репозиторій у вашому застосунку має розпарсити ідентифікатор, перевірити його за схемою і лише потім виконати запит до конкретного сховища. Інтерфейс не може зафіксувати цю спільну послідовність, оскільки він не може містити виконуваний код. Абстрактний клас — може:

abstract class BaseRepository<T> {
  protected validateId(id: string): boolean {
    return /^[a-z0-9\-]+$/.test(id);
  }

  abstract fetchById(id: string): Promise<T | null>;

  async findById(id: string): Promise<T | null> {
    if (!this.validateId(id)) throw new Error("Invalid ID format");
    return this.fetchById(id);
  }
}

BaseRepository забезпечує структуру. Він вимагає, щоб підкласи реалізували fetchById. Але він також надає готову логіку. Підкласи безкоштовно отримують обмеження та шаблонний код (boilerplate). Якби ви спробували замінити це інтерфейсом, вам довелося б копіювати validateId та findById у кожен репозиторій. Це не абстракція. Це податок на підтримку коду.

Єдине питання, яке все вирішує

Ваш вибір завжди має зводитися до одного питання: Чи має цей контракт містити спільний код?

Якщо відповідь «ні» — використовуйте інтерфейс. Якщо «так» — використовуйте абстрактний клас.

Помилка в цьому питанні має негайні наслідки. Якщо ви використовуєте абстрактний клас там, де було б достатньо простої структури, ви змушуєте кожну реалізацію входити в ланцюжок успадкування. Юніт-тести раптом потребують незручного мокінгу або часткових заглушок (stubs) реального класу. Сторонні розширення змушені успадковувати ваш базовий клас замість того, щоб просто відповідати структурі. Ви перетворили простий контракт на цілу гору.

З іншого боку, якщо ви використовуєте інтерфейс там, де існує спільна поведінка, ви розкидаєте копії однієї й тієї ж логіки по всьому коду. Коли в цій логіці з'явиться баг, ви не зможете виправити його в одному місці. Вам доведеться шукати його в десяти реалізаціях і сподіватися, що ви не пропустите одинадцяту.

Як вони насправді відрізняються на практиці

Окрім філософського розмежування, між ними є три практичні відмінності.

Продуктивність та розмір бандла. Інтерфейси видаляються під час компіляції. Вони не додають жодної ваги вашому JavaScript-коду та не споживають оперативну пам'ять під час виконання. Абстрактні класи компілюються у реальні функції-конструктори та ланцюжки прототипів. Кожен визначений вами клас збільшує розмір бандла та займає місце в пам'яті після створення екземпляра. Для сервера, що обробляє тисячі екземплярів, або для фронтенд-бандла, що підлягає оптимізації, ця різниця не є теоретичною.

Гнучкість композиції. Один клас може реалізовувати багато інтерфейсів одночасно. Ви можете створити FileCache, який одночасно реалізує Cache, Disposable та EventEmitter. TypeScript буде задоволений, оскільки інтерфейси не створюють ієрархії. Однак клас може успадковувати лише один абстрактний клас. Ланцюжок прототипів JavaScript не підтримує множинне успадкування. Якщо ви будете занадто сильно покладатися на абстрактні класи, ви зрештою зіткнетеся з класичною дилемою спроби об'єднати два базові класи, які обидва містять