Wiele zespołów TypeScript traktuje abstract class i interface jak zamienne nadzienia w tej samej kanapce. Tak nie jest. Wybór niewłaściwego narzędzia pozostawia realne blizny: powieloną logikę biznesową, sztywne drzewa klas, które stawiają opór każdemu refaktoringowi, oraz subtelne błędy w czasie wykonywania (runtime), których źródło prowadzi do klasy bazowej, którą ktoś uznał za bezpieczną. Rozwiązaniem nie jest zapamiętanie podręcznika zasad. Chodzi o nauczenie się analizowania rzeczywistych wymagań i wybierania narzędzia, które im odpowiada.
Interfejsy to czyste kontrakty
Interfejs to granica w czasie kompilacji. Opisuje, jak obiekt musi wyglądać i co musi potrafić robić, ale nie dostarcza żadnej implementacji. Po skompilowaniu TypeScripta do JavaScriptu, interfejs całkowicie znika. Nie zostawia po sobie konstruktora, łańcucha prototypów ani dodatkowych bajtów w Twoim bundle'u.
Wyobraź sobie, że budujesz system powiadomień. Definiujesz interfejs:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
Każda klasa, a nawet zwykły obiekt literalny, który spełnia ten kształt, jest poprawnym Notifier. EmailNotifier może go implementować. Tak samo SmsNotifier, SlackNotifier czy obiekt typu mock przekazywany do testu jednostkowego. Ponieważ interfejs nie zawiera żadnego kodu, każda implementacja pisze własną metodę send od zera. To jest dokładnie to, czego chcesz, gdy implementacje nie dzielą niczego poza swoim publicznym interfejsem.
Klasy abstrakcyjne niosą ze sobą realny kod
Klasa abstrakcyjna to pełnoprawna klasa, która po prostu zabrania bezpośredniej instancjacji. Może definiować pola, konkretne metody z logiką oraz logikę konstruktora. Zmusza podklasy do uzupełnienia braków za pomocą metod abstrakcyjnych, ale daje im również dziedziczone zachowania, których nie muszą pisać samodzielnie.
Wyobraź sobie warstwę dostępu do danych. Każde repozytorium w Twojej aplikacji musi sparsować identyfikator, zweryfikować go względem schematu, a dopiero potem wykonać zapytanie specyficzne dla danego magazynu danych. Interfejs nie może uchwycić tej wspólnej sekwencji, ponieważ nie może zawierać wykonywalnego kodu. Klasa abstrakcyjna może:
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 wymusza strukturę. Wymaga, aby podklasy implementowały fetchById. Dostarcza jednak również działającą logikę. Podklasy otrzymują zabezpieczenia i boilerplate za darmo. Gdybyś próbował zastąpić to interfejsem, skończyłbyś na kopiowaniu validateId i findById do każdego pojedynczego repozytorium. To nie jest abstrakcja. To podatek od utrzymania kodu.
Jedno pytanie, które wszystko rozstrzyga
Twój wybór powinien zawsze sprowadzać się do jednego pytania: Czy ten kontrakt musi dostarczać współdzielony kod?
Jeśli odpowiedź brzmi „nie”, użyj interfejsu. Jeśli „tak”, użyj klasy abstrakcyjnej.
Błędna decyzja niesie natychmiastowe konsekwencje. Jeśli użyjesz klasy abstrakcyjnej tam, gdzie wystarczyłby sam kształt (shape), zmusisz każdą implementację do wejścia w łańcuch dziedziczenia. Testy jednostkowe nagle wymagają niezdarnego mockowania lub częściowych stubów rzeczywistej klasy. Rozszerzenia firm trzecich będą musiały dziedziczyć po Twojej klasie bazowej, zamiast po prostu dopasować się do kształtu. Zamieniłeś prosty kontrakt w górę nie do przebycia.
Z drugiej strony, jeśli użyjesz interfejsu tam, gdzie istnieje wspólne zachowanie, rozproszysz kopie tej samej logiki w całym kodzie. Gdy ta logika zawiera błąd, nie naprawisz go w jednym miejscu. Będziesz go szukać w dziesięciu implementacjach, mając nadzieję, że nie pominiesz jedenastej.
Jak właściwie różnią się w praktyce
Poza filozoficznym podziałem, te dwa podejścia różnią się w trzech praktycznych aspektach.
Wydajność i rozmiar bundle'a. Interfejsy są usuwane podczas kompilacji. Nie dodają żadnej wagi do Twojego wyjściowego kodu JavaScript i nie zużywają pamięci w czasie wykonywania. Klasy abstrakcyjne kompilują się do rzeczywistych funkcji konstruktora i łańcuchów prototypów. Każda zdefiniowana klasa zwiększa rozmiar bundle'a i zajmuje miejsce w pamięci po zainicjowaniu. Na serwerze obsługującym tysiące instancji lub w bundle'u frontendowym poddanym rygorystycznej optymalizacji, ta różnica nie jest jedynie teoretyczna.
Elastyczność kompozycji. Pojedyncza klasa może implementować wiele interfejsów jednocześnie. Możesz zbudować FileCache, który implementuje Cache, Disposable i EventEmitter za jednym zamachem. TypeScript jest zadowolony, ponieważ interfejsy nie narzucają hierarchii. Klasa może jednak rozszerzać tylko jedną klasę abstrakcyjną. Łańcuch prototypów w JavaScript nie obsługuje wielokrotnego dziedziczenia. Jeśli będziesz zbyt mocno polegać na klasach abstrakcyjnych, w końcu staniesz przed klasycznym dylematem próby połączenia dwóch klas bazowych, z których każda zawiera
