Nhiều đội ngũ TypeScript coi abstract class và interface như những thành phần có thể thay thế cho nhau trong cùng một chiếc sandwich. Thực tế không phải vậy. Việc chọn sai công cụ sẽ để lại những "vết sẹo" thực sự: logic nghiệp vụ bị lặp lại, các cây lớp (class trees) cứng nhắc gây khó khăn cho mọi lần refactor, và những lỗi runtime tinh vi bắt nguồn từ một lớp cơ sở mà ai đó từng cho là an toàn. Cách khắc phục không phải là học thuộc lòng một cuốn sách quy tắc. Đó là học cách nhìn vào các yêu cầu thực tế và chọn công cụ phù hợp với chúng.
Interface là những bản hợp đồng thuần túy
Một interface là một ranh giới tại thời điểm biên dịch (compile-time). Nó mô tả hình dáng của một đối tượng và những gì nó có thể làm, nhưng không đi kèm bất kỳ phần triển khai (implementation) nào. Sau khi TypeScript biên dịch sang JavaScript, interface sẽ biến mất hoàn toàn. Nó không để lại constructor, không có prototype chain và không làm tăng thêm bất kỳ byte nào trong bundle của bạn.
Hãy tưởng tượng bạn đang xây dựng một hệ thống thông báo. Bạn định nghĩa một interface:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
Bất kỳ class nào, hoặc thậm chí là một object literal thông thường, thỏa mãn hình dáng đó đều là một Notifier hợp lệ. Một EmailNotifier có thể implement nó. SmsNotifier, SlackNotifier, hoặc một mock object mà bạn dùng trong unit test cũng vậy. Vì interface không chứa mã nguồn, mỗi phần triển khai sẽ tự viết phương thức send của riêng mình từ đầu. Đó chính xác là những gì bạn muốn khi các phần triển khai không chia sẻ bất cứ điều gì ngoại trừ giao diện công khai (public face) của chúng.
Abstract Class mang theo mã nguồn thực tế
Một abstract class là một class đầy đủ nhưng bị cấm khởi tạo trực tiếp. Nó có thể định nghĩa các field, các phương thức cụ thể (concrete methods) có logic, và cả logic của constructor. Nó buộc các lớp con (subclasses) phải điền vào các chỗ trống thông qua các phương thức abstract, nhưng đồng thời cũng cung cấp cho chúng các hành vi được kế thừa mà chúng không cần phải tự viết lại.
Hãy hình dung về một lớp truy cập dữ liệu (data access layer). Mọi repository trong ứng dụng của bạn đều cần phân tích một identifier, xác thực nó với một schema, và chỉ sau đó mới chạy một truy vấn đặc thù cho kho lưu trữ. Một interface không thể nắm bắt được trình tự chung đó vì interface không thể chứa mã thực thi. Một abstract class thì có thể:
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 áp đặt cấu trúc. Nó yêu cầu các lớp con phải implement fetchById. Nhưng nó cũng cung cấp logic sẵn có. Các lớp con nhận được các rào chắn (guardrails) và mã mẫu (boilerplate) một cách miễn phí. Nếu bạn cố gắng thay thế điều này bằng một interface, bạn sẽ kết thúc bằng việc phải sao chép validateId và findById vào mọi repository. Đó không phải là trừu tượng hóa (abstraction). Đó là một "chi phí bảo trì" (maintenance tax).
Câu hỏi duy nhất để quyết định
Lựa chọn của bạn luôn nên xoay quanh một câu hỏi duy nhất: Bản hợp đồng này có cần đi kèm với mã nguồn dùng chung không?
Nếu câu trả lời là không, hãy dùng interface. Nếu câu trả lời là có, hãy dùng abstract class.
Việc nhầm lẫn này sẽ dẫn đến những hậu quả tức thì. Nếu bạn sử dụng một abstract class trong khi chỉ cần một
