Banyak tim TypeScript menganggap abstract class dan interface sebagai isian yang bisa saling menggantikan dalam satu sandwich yang sama. Padahal tidak. Menggunakan yang salah akan menimbulkan dampak nyata: logika bisnis yang duplikat, struktur kelas yang kaku sehingga sulit direfaktorisasi, dan error runtime halus yang berakar dari base class yang dianggap aman oleh seseorang. Solusinya bukanlah menghafal buku aturan. Melainkan belajar melihat kebutuhan aktual Anda dan memilih alat yang sesuai.

Interface Adalah Kontrak Murni

Sebuah interface adalah batasan pada saat kompilasi (compile-time boundary). Ia mendeskripsikan bagaimana bentuk sebuah objek dan apa yang harus bisa ia lakukan, tetapi tidak menyertakan implementasi apa pun. Setelah TypeScript dikompilasi menjadi JavaScript, interface tersebut akan hilang sepenuhnya. Ia tidak meninggalkan constructor, prototype chain, maupun byte tambahan dalam bundle Anda.

Bayangkan Anda sedang membangun sistem notifikasi. Anda mendefinisikan sebuah interface:

interface Notifier {
  send(message: string): void;
  readonly channel: string;
}

Kelas apa pun, atau bahkan object literal biasa, yang memenuhi bentuk tersebut adalah Notifier yang valid. EmailNotifier dapat mengimplementasikannya. Begitu juga dengan SmsNotifier, SlackNotifier, atau objek mock yang Anda berikan pada unit test. Karena interface tidak membawa kode, setiap implementasi menulis metode send-nya sendiri dari awal. Itulah yang Anda inginkan ketika setiap implementasi tidak berbagi apa pun selain tampilan publiknya.

Abstract Class Membawa Kode Nyata

Sebuah abstract class adalah kelas lengkap yang kebetulan melarang instansiasi secara langsung. Ia dapat mendefinisikan field, metode konkret dengan logika, dan logika constructor. Ia memaksa subclass untuk mengisi bagian yang kosong melalui metode abstrak, tetapi ia juga memberikan perilaku warisan (inherited behavior) yang tidak perlu ditulis sendiri oleh subclass tersebut.

Bayangkan sebuah lapisan akses data (data access layer). Setiap repository di aplikasi Anda perlu memparsing sebuah identifier, memvalidasinya terhadap skema, dan baru kemudian menjalankan query spesifik penyimpanan. Sebuah interface tidak dapat menangkap urutan bersama tersebut karena interface tidak dapat berisi kode yang dapat dieksekusi. Sebuah abstract class bisa:

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 menegakkan struktur. Ia menuntut subclass untuk mengimplementasikan fetchById. Namun, ia juga menyediakan logika yang sudah jadi. Subclass mendapatkan pembatas (guardrails) dan boilerplate secara gratis. Jika Anda mencoba menggantinya dengan interface, Anda akan berakhir dengan menyalin validateId dan findById ke setiap repository. Itu bukan abstraksi. Itu adalah beban pemeliharaan (maintenance tax).

Satu Pertanyaan yang Menentukannya

Pilihan Anda harus selalu bermuara pada satu pertanyaan: Apakah kontrak ini perlu menyertakan kode bersama?

Jika jawabannya tidak, gunakan interface. Jika jawabannya ya, gunakan abstract class.

Salah dalam menentukan hal ini memiliki konsekuensi langsung. Jika Anda menggunakan abstract class padahal hanya butuh sebuah bentuk (shape), Anda memaksa setiap implementasi masuk ke dalam rantai pewarisan (inheritance chain). Unit test tiba-tiba membutuhkan mocking yang canggung atau stub parsial dari kelas nyata. Ekstensi pihak ketiga harus membuat subclass dari base class Anda daripada sekadar menyesuaikan bentuknya. Anda telah mengubah kontrak sederhana menjadi sebuah gunung yang berat.

Sebaliknya, jika Anda menggunakan interface padahal ada perilaku bersama yang ada, Anda akan menyebarkan salinan logika yang sama di seluruh codebase Anda. Ketika logika tersebut mengandung bug, Anda tidak bisa memperbaikinya di satu tempat. Anda harus mencari di sepuluh implementasi dan berharap tidak melewatkan yang kesebelas.

Perbedaan Nyata dalam Praktik

Di luar perbedaan filosofis, ada tiga celah praktis yang memisahkan keduanya.

Performa dan ukuran bundle. Interface dihapus selama kompilasi. Mereka tidak menambah beban sama sekali pada output JavaScript Anda dan tidak mengonsumsi memori runtime. Abstract class dikompilasi menjadi fungsi constructor nyata dan prototype chain. Setiap class yang Anda definisikan menambah ukuran bundle dan menempati memori saat diinstansiasi. Pada server yang menangani ribuan instansi, atau bundle frontend yang sedang diperiksa secara mendalam, perbedaan tersebut bukanlah hal teoretis.

Fleksibilitas komposisi. Sebuah kelas dapat mengimplementasikan banyak interface sekaligus. Anda mungkin membangun FileCache yang mengimplementasikan Cache, Disposable, dan EventEmitter dalam satu waktu. TypeScript akan menerimanya karena interface tidak memaksakan hierarki. Namun, sebuah kelas hanya dapat memperluas (extend) satu abstract class. Prototype chain JavaScript tidak mendukung multiple inheritance. Jika Anda terlalu bergantung pada abstract class, Anda pada akhirnya akan menghadapi dilema klasik saat mencoba menggabungkan dua base class yang keduanya berisi