Muitas equipes de TypeScript tratam abstract class e interface como recheios intercambiáveis para o mesmo sanduíche. Elas não são. Escolher a errada produz cicatrizes reais: lógica de negócio duplicada, árvores de classes rígidas que resistem a qualquer refatoração e erros sutis de tempo de execução que remontam a uma classe base que alguém assumiu ser segura. A solução não é memorizar um manual de regras. É aprender a olhar para seus requisitos reais e escolher a ferramenta que corresponda a eles.
Interfaces São Contratos Puros
Uma interface é um limite em tempo de compilação. Ela descreve como um objeto deve parecer e o que ele deve ser capaz de fazer, mas não entrega nenhuma implementação. Depois que o TypeScript é compilado para JavaScript, a interface desaparece inteiramente. Ela não deixa construtor, nem cadeia de protótipos, nem bytes extras no seu bundle.
Imagine que você está construindo um sistema de notificação. Você define uma interface:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
Qualquer classe, ou mesmo um objeto literal simples, que satisfaça esse formato é um Notifier válido. Um EmailNotifier pode implementá-lo. O mesmo vale para um SmsNotifier, um SlackNotifier ou um objeto mock que você passa para um teste unitário. Como a interface não carrega código, cada implementação escreve seu próprio método send do zero. É exatamente isso que você quer quando as implementações não compartilham nada além de sua face pública.
Abstract Classes Carregam Código Real
Uma classe abstrata é uma classe completa que, por acaso, proíbe a instanciação direta. Ela pode definir campos, métodos concretos com lógica e lógica de construtor. Ela força as subclasses a preencherem as lacunas por meio de métodos abstratos, mas também lhes fornece um comportamento herdado que elas não precisam escrever sozinhas.
Imagine uma camada de acesso a dados. Cada repositório em sua aplicação precisa analisar um identificador, validá-lo contra um esquema e, só então, executar uma consulta específica de armazenamento. Uma interface não consegue capturar essa sequência compartilhada porque uma interface não pode conter código executável. Uma classe abstrata pode:
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);
}
}
A BaseRepository impõe uma estrutura. Ela exige que as subclasses implementem fetchById. Mas ela também fornece lógica funcional. As
