Molti team TypeScript trattano abstract class e interface come ingredienti intercambiabili dello stesso panino. Non lo sono. Scegliere quella sbagliata produce cicatrici reali: logica di business duplicata, alberi di classi rigidi che resistono a ogni refactoring e sottili errori a runtime che risalgono a una classe base che qualcuno ha dato per sicura. La soluzione non è memorizzare un manuale di regole. Consiste nell'imparare a guardare i propri requisiti reali e scegliere lo strumento che li soddisfa.
Le interfacce sono contratti puri
Un'interfaccia è un confine a tempo di compilazione. Descrive come deve apparire un oggetto e cosa deve essere in grado di fare, ma non fornisce alcuna implementazione. Dopo che TypeScript viene compilato in JavaScript, l'interfaccia svanisce completamente. Non lascia alcun costruttore, nessuna catena di prototipi e nessun byte extra nel bundle.
Immagina di stare costruendo un sistema di notifica. Definisci un'interfaccia:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
Qualsiasi classe, o persino un semplice oggetto letterale, che soddisfi quella forma è un Notifier valido. Un EmailNotifier può implementarla. Lo stesso vale per un SmsNotifier, un SlackNotifier o un oggetto mock da passare a un test unitario. Poiché l'interfaccia non contiene codice, ogni implementazione scrive il proprio metodo send da zero. È esattamente ciò che desideri quando le implementazioni non condividono nulla se non la loro interfaccia pubblica.
Le classi astratte contengono codice reale
Una classe astratta è una classe a tutti gli effetti che, per caso, proibisce l'istanziazione diretta. Può definire campi, metodi concreti con logica e logica del costruttore. Obbliga le sottoclassi a colmare le lacune attraverso metodi astratti, ma fornisce loro anche un comportamento ereditato che non devono scrivere da sole.
Immagina uno strato di accesso ai dati (data access layer). Ogni repository nella tua applicazione deve analizzare un identificatore, validarlo rispetto a uno schema e solo allora eseguire una query specifica per l'archiviazione. Un'interfaccia non può catturare questa sequenza condivisa perché non può contenere codice eseguibile. Una classe astratta sì:
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 impone una struttura. Richiede che le sottoclassi implementino fetchById. Ma fornisce anche una logica funzionante. Le sottoclassi ottengono gratuitamente i binari di sicurezza e il boilerplate. Se provassi a sostituire questo con un'interfaccia, finiresti per copiare validateId e findById in ogni singolo repository. Questa non è astrazione. È una tassa sulla manutenzione.
L'unica domanda che decide tutto
La tua scelta dovrebbe sempre ridursi a una singola domanda: Questo contratto deve fornire codice condiviso?
Se la risposta è no, usa un'interfaccia. Se la risposta è sì, usa una classe astratta.
Sbagliare questa scelta ha conseguenze immediate. Se usi una classe astratta dove basterebbe una forma, costringi ogni implementazione in una catena di ereditarietà. I test unitari richiedono improvvisamente un mocking scomodo o stub parziali di una classe reale. Le estensioni di terze parti devono ereditare dalla tua classe base invece di limitarsi a rispettare una forma. Hai trasformato un semplice contratto in una montagna.
D'altro canto, se usi un'interfaccia dove esiste un comportamento condiviso, disperdi copie della stessa logica in tutto il codice. Quando quella logica contiene un bug, non lo correggi in un unico punto. Devi cercarlo tra dieci implementazioni sperando di non mancare l'undicesima.
Come differiscono realmente nella pratica
Oltre alla distinzione filosofica, tre lacune pratiche separano le due.
Performance e dimensione del bundle. Le interfacce vengono eliminate durante la compilazione. Non aggiungono alcun peso al tuo output JavaScript e non consumano memoria a runtime. Le classi astratte vengono compilate in vere funzioni costruttore e catene di prototipi. Ognuna di esse che definisci si aggiunge al bundle e occupa memoria quando viene istanziata. Su un server che gestisce migliaia di istanze, o su un bundle frontend sotto esame, questa differenza non è teorica.
Flessibilità della composizione. Una singola classe può implementare più interfacce contemporaneamente. Potresti costruire un FileCache che implementa Cache, Disposable ed EventEmitter in un colpo solo. TypeScript è soddisfatto perché le interfacce non impongono gerarchie. Una classe, tuttavia, può estendere solo una classe astratta. La catena di prototipi di JavaScript non supporta l'ereditarietà multipla. Se fai troppo affidamento sulle classi astratte, alla fine ti troverai di fronte al classico dilemma di cercare di fondere due classi base che contengono entrambe
