Многие команды TypeScript относятся к abstract class и interface как к взаимозаменяемым начинкам для одного и того же сэндвича. Это не так. Использование неверного инструмента приводит к реальным проблемам: дублированию бизнес-логики, жестким деревьям классов, которые сопротивляются любому рефакторингу, и трудноуловимым ошибкам времени выполнения, корни которых уходят в базовый класс, который кто-то посчитал безопасным. Решение заключается не в заучивании свода правил, а в умении анализировать реальные требования и выбирать подходящий инструмент.
Интерфейсы — это чистые контракты
Интерфейс — это граница на этапе компиляции. Он описывает, как должен выглядеть объект и что он должен уметь делать, но не содержит никакой реализации. После того как TypeScript компилируется в JavaScript, интерфейс полностью исчезает. Он не оставляет ни конструктора, ни цепочки прототипов, ни лишних байтов в вашем бандле.
Представьте, что вы создаете систему уведомлений. Вы определяете интерфейс:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
Любой класс или даже обычный объектный литерал, соответствующий этой структуре, является валидным Notifier. EmailNotifier может реализовать его. То же самое касается SmsNotifier, SlackNotifier или мок-объекта, который вы передаете в юнит-тест. Поскольку интерфейс не несет в себе кода, каждая реализация пишет свой собственный метод 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. Но он также предоставляет готовую логику. Подклассы бесплатно получают защитные механизмы и шаблонный код. Если бы вы попытались заменить это интерфейсом, вам пришлось бы копировать validateId и findById в каждый репозиторий. Это не абстракция, это налог на поддержку.
Один вопрос, который все решает
Ваш выбор всегда должен сводиться к одному вопросу: Должен ли этот контракт содержать общий код?
Если ответ «нет», используйте интерфейс. Если ответ «да», используйте абстрактный класс.
Ошибка в этом выборе влечет за собой немедленные последствия. Если вы используете абстрактный класс там, где достаточно просто описать структуру, вы принуждаете каждую реализацию к участию в цепочке наследования. Юнит-тесты внезапно начинают требовать неудобного мокирования или частичных заглушек реального класса. Сторонние расширения будут вынуждены наследоваться от вашего базового класса вместо того, чтобы просто соответствовать структуре. Вы превратили простой контракт в гору.
С другой стороны, если вы используете интерфейс там, где существует общее поведение, вы разбрасываете копии одной и той же логики по всей кодовой базе. Когда в этой логике обнаружится баг, вам не удастся исправить его в одном месте. Вам придется искать его в десяти реализациях и надеяться, что вы не пропустите одиннадцатую.
Как они на самом деле различаются на практике
Помимо философского различия, между ними существуют три практических разрыва.
Производительность и размер бандла. Интерфейсы удаляются во время компиляции. Они не добавляют никакого веса вашему JavaScript-коду и не потребляют оперативную память во время выполнения. Абстрактные классы компилируются в реальные функции-конструкторы и цепочки прототипов. Каждый определенный вами класс увеличивает размер бандла и занимает место в памяти при создании экземпляра. Для сервера, обрабатывающего тысячи экземпляров, или для фронтенд-бандла, оптимизация которого критична, эта разница не является чисто теоретической.
Гибкость композиции. Один класс может реализовывать множество интерфейсов одновременно. Вы можете создать FileCache, который реализует Cache, Disposable и EventEmitter одним махом. TypeScript будет доволен, так как интерфейсы не навязывают иерархию. Однако класс может наследоваться только от одного абстрактного класса. Цепочка прототипов JavaScript не поддерживает множественное наследование. Если вы будете слишком сильно полагаться на абстрактные классы, вы в конечном итоге столкнетесь с классической дилеммой попытки объединить два базовых класса, которые оба содержат
