Jelajahi pola OOP TypeScript tingkat lanjut. Panduan ini membahas prinsip desain kelas, perdebatan pewarisan vs. komposisi, dan strategi praktis.
Pola OOP TypeScript: Panduan untuk Desain Kelas dan Strategi Pewarisan
Di dunia pengembangan perangkat lunak modern, TypeScript telah muncul sebagai landasan untuk membangun aplikasi yang kuat, terukur, dan mudah dipelihara. Sistem pengetikan yang kuat, yang dibangun di atas JavaScript, memberi pengembang alat untuk menangkap kesalahan lebih awal dan menulis kode yang lebih mudah diprediksi. Inti dari kekuatan TypeScript terletak pada dukungan komprehensifnya untuk prinsip Pemrograman Berorientasi Objek (OOP). Namun, hanya mengetahui cara membuat kelas saja tidak cukup. Menguasai TypeScript membutuhkan pemahaman mendalam tentang desain kelas, hierarki pewarisan, dan pertukaran antara pola arsitektur yang berbeda.
Panduan ini dirancang untuk audiens global pengembang, mulai dari mereka yang memperkuat keterampilan menengah mereka hingga arsitek berpengalaman. Kita akan menyelami konsep inti OOP di TypeScript, menjelajahi strategi desain kelas yang efektif, dan mengatasi perdebatan lama: pewarisan versus komposisi. Pada akhirnya, Anda akan dilengkapi dengan pengetahuan untuk membuat keputusan desain yang tepat yang mengarah pada basis kode yang lebih bersih, lebih fleksibel, dan tahan masa depan.
Memahami Pilar OOP di TypeScript
Sebelum kita mempelajari pola yang kompleks, mari kita bangun fondasi yang kuat dengan meninjau kembali empat pilar fundamental Pemrograman Berorientasi Objek sebagaimana diterapkan pada TypeScript.
1. Enkapsulasi
Enkapsulasi adalah prinsip menggabungkan data (properti) objek dan metode yang beroperasi pada data tersebut ke dalam satu unit—sebuah kelas. Ini juga melibatkan pembatasan akses langsung ke keadaan internal objek. TypeScript mencapai ini terutama melalui pengubah akses: public, private, dan protected.
Contoh: Rekening bank di mana saldo hanya dapat diubah melalui metode penyetoran dan penarikan.
class BankAccount {
private balance: number = 0;
constructor(initialBalance: number) {
if (initialBalance >= 0) {
this.balance = initialBalance;
}
}
public deposit(amount: number): void {
if (amount > 0) {
this.balance += amount;
console.log(`Deposited: ${amount}. New balance: ${this.balance}`);
}
}
public getBalance(): number {
// We expose the balance through a method, not directly
return this.balance;
}
}
2. Abstraksi
Abstraksi berarti menyembunyikan detail implementasi yang kompleks dan hanya mengekspos fitur-fitur penting dari suatu objek. Ini memungkinkan kita untuk bekerja dengan konsep tingkat tinggi tanpa perlu memahami mesin yang rumit di bawahnya. Dalam TypeScript, abstraksi sering dicapai menggunakan kelas abstract dan interfaces.
Contoh: Saat Anda menggunakan remote control, Anda hanya menekan tombol "Power". Anda tidak perlu tahu tentang sinyal inframerah atau sirkuit internal. Remote menyediakan antarmuka abstrak ke fungsionalitas TV.
3. Pewarisan
Pewarisan adalah mekanisme di mana kelas baru (subkelas atau kelas turunan) mewarisi properti dan metode dari kelas yang ada (superkelas atau kelas dasar). Ini mempromosikan penggunaan kembali kode dan menetapkan hubungan "is-a" yang jelas antara kelas. TypeScript menggunakan kata kunci extends untuk pewarisan.
Contoh: Seorang `Manager` "is-a" jenis `Employee`. Mereka berbagi properti umum seperti `name` dan `id`, tetapi `Manager` mungkin memiliki properti tambahan seperti `subordinates`.
class Employee {
constructor(public name: string, public id: number) {}
getProfile(): string {
return `Name: ${this.name}, ID: ${this.id}`;
}
}
class Manager extends Employee {
constructor(name: string, id: number, public subordinates: Employee[]) {
super(name, id); // Call the parent constructor
}
// Managers can also have their own methods
delegateTask(): void {
console.log(`${this.name} is delegating tasks.`);
}
}
4. Polimorfisme
Polimorfisme, yang berarti "banyak bentuk", memungkinkan objek dari kelas yang berbeda diperlakukan sebagai objek dari superkelas umum. Ini memungkinkan satu antarmuka (seperti nama metode) untuk mewakili bentuk dasar (implementasi) yang berbeda. Ini sering dicapai melalui penggantian metode.
Contoh: Metode `render()` yang berperilaku berbeda untuk objek `Circle` versus objek `Square`, meskipun keduanya adalah `Shape`s.
abstract class Shape {
abstract draw(): void; // An abstract method must be implemented by subclasses
}
class Circle extends Shape {
draw(): void {
console.log("Drawing a circle.");
}
}
class Square extends Shape {
draw(): void {
console.log("Drawing a square.");
}
}
function renderShapes(shapes: Shape[]): void {
shapes.forEach(shape => shape.draw()); // Polymorphism in action!
}
const myShapes: Shape[] = [new Circle(), new Square()];
renderShapes(myShapes);
// Output:
// Drawing a circle.
// Drawing a square.
Perdebatan Besar: Pewarisan vs. Komposisi
Ini adalah salah satu keputusan desain paling penting dalam OOP. Kebijaksanaan umum dalam rekayasa perangkat lunak modern adalah "mendukung komposisi daripada pewarisan." Mari kita pahami mengapa dengan menjelajahi kedua konsep tersebut secara mendalam.
Apa itu Pewarisan? Hubungan "is-a"
Pewarisan menciptakan keterikatan yang kuat antara kelas dasar dan kelas turunan. Ketika Anda menggunakan `extends`, Anda menyatakan bahwa kelas baru adalah versi khusus dari kelas dasar. Ini adalah alat yang ampuh untuk penggunaan kembali kode ketika ada hubungan hierarkis yang jelas.
- Pro:
- Penggunaan Kembali Kode: Logika umum didefinisikan sekali dalam kelas dasar.
- Polimorfisme: Memungkinkan perilaku polimorfik yang elegan, seperti yang terlihat dalam contoh `Shape` kita.
- Hierarki yang Jelas: Ini memodelkan sistem klasifikasi top-down dunia nyata.
- Kontra:
- Keterikatan yang Kuat: Perubahan pada kelas dasar secara tidak sengaja dapat merusak kelas turunan. Ini dikenal sebagai "masalah kelas dasar yang rapuh."
- Neraka Hierarki: Penggunaan berlebihan dapat menyebabkan rantai pewarisan yang dalam, kompleks, dan kaku yang sulit dipahami dan dipelihara.
- Tidak Fleksibel: Sebuah kelas hanya dapat mewarisi dari satu kelas lain di TypeScript (pewarisan tunggal), yang dapat membatasi. Anda tidak dapat mewarisi fitur dari beberapa kelas yang tidak terkait.
Kapan Pewarisan Menjadi Pilihan yang Baik?
Gunakan pewarisan ketika hubungannya benar-benar "is-a" dan stabil serta tidak mungkin berubah. Misalnya, `CheckingAccount` dan `SavingsAccount` pada dasarnya adalah jenis `BankAccount`. Hierarki ini masuk akal dan tidak mungkin diubah.
Apa itu Komposisi? Hubungan "has-a"
Komposisi melibatkan pembangunan objek kompleks dari objek yang lebih kecil dan independen. Alih-alih kelas menjadi sesuatu yang lain, ia memiliki objek lain yang menyediakan fungsionalitas yang diperlukan. Ini menciptakan keterikatan yang longgar, karena kelas hanya berinteraksi dengan antarmuka publik dari objek yang dikomposisikan.
- Pro:
- Fleksibilitas: Fungsionalitas dapat diubah saat runtime dengan menukar objek yang dikomposisikan.
- Keterikatan yang Longgar: Kelas yang berisi tidak perlu mengetahui cara kerja internal komponen yang digunakannya. Ini membuat kode lebih mudah diuji dan dipelihara.
- Menghindari Masalah Hierarki: Anda dapat menggabungkan fungsionalitas dari berbagai sumber tanpa membuat pohon pewarisan yang kusut.
- Tanggung Jawab yang Jelas: Setiap kelas komponen dapat mematuhi Prinsip Tanggung Jawab Tunggal.
- Kontra:
- Lebih Banyak Boilerplate: Terkadang memerlukan lebih banyak kode untuk menghubungkan berbagai komponen dibandingkan dengan model pewarisan sederhana.
- Kurang Intuitif untuk Hierarki: Ini tidak memodelkan taksonomi alami secara langsung seperti halnya pewarisan.
Contoh Praktis: Mobil
Sebuah `Car` adalah contoh sempurna dari komposisi. Sebuah `Car` bukanlah jenis `Engine`, juga bukan jenis `Wheel`. Sebaliknya, sebuah `Car` memiliki `Engine` dan memiliki `Wheels`.
// Component classes
class Engine {
start() {
console.log("Engine starting...");
}
}
class GPS {
navigate(destination: string) {
console.log(`Navigating to ${destination}...`);
}
}
// The composite class
class Car {
private readonly engine: Engine;
private readonly gps: GPS;
constructor() {
// The Car creates its own parts
this.engine = new Engine();
this.gps = new GPS();
}
driveTo(destination: string) {
this.engine.start();
this.gps.navigate(destination);
console.log("Car is on its way.");
}
}
const myCar = new Car();
myCar.driveTo("New York City");
Desain ini sangat fleksibel. Jika kita ingin membuat `Car` dengan `ElectricEngine`, kita tidak memerlukan rantai pewarisan baru. Kita dapat menggunakan Dependency Injection untuk menyediakan `Car` dengan komponen-komponennya, menjadikannya lebih modular.
interface IEngine {
start(): void;
}
class PetrolEngine implements IEngine {
start() { console.log("Petrol engine starting..."); }
}
class ElectricEngine implements IEngine {
start() { console.log("Silent electric engine starting..."); }
}
class AdvancedCar {
// The car depends on an abstraction (interface), not a concrete class
constructor(private engine: IEngine) {}
startJourney() {
this.engine.start();
console.log("Journey has begun.");
}
}
const tesla = new AdvancedCar(new ElectricEngine());
tesla.startJourney();
const ford = new AdvancedCar(new PetrolEngine());
ford.startJourney();
Strategi dan Pola Tingkat Lanjut di TypeScript
Di luar pilihan dasar antara pewarisan dan komposisi, TypeScript menyediakan alat yang ampuh untuk membuat desain kelas yang canggih dan fleksibel.
1. Kelas Abstrak: Cetak Biru untuk Pewarisan
Ketika Anda memiliki hubungan "is-a" yang kuat tetapi ingin memastikan bahwa kelas dasar tidak dapat diinstansiasi sendiri, gunakan kelas `abstract`. Mereka bertindak sebagai cetak biru, mendefinisikan metode dan properti umum, dan dapat mendeklarasikan metode `abstract` yang harus diimplementasikan oleh kelas turunan.
Kasus Penggunaan: Sistem pemrosesan pembayaran. Anda tahu setiap gateway harus memiliki metode `pay()` dan `refund()`, tetapi implementasinya khusus untuk setiap penyedia (misalnya, Stripe, PayPal).
abstract class PaymentGateway {
constructor(public apiKey: string) {}
// A concrete method shared by all subclasses
protected connect(): void {
console.log("Connecting to payment service...");
}
// Abstract methods that subclasses must implement
abstract processPayment(amount: number): boolean;
abstract issueRefund(transactionId: string): boolean;
}
class StripeGateway extends PaymentGateway {
processPayment(amount: number): boolean {
this.connect();
console.log(`Processing ${amount} via Stripe.`);
return true;
}
issueRefund(transactionId: string): boolean {
console.log(`Refunding transaction ${transactionId} via Stripe.`);
return true;
}
}
// const gateway = new PaymentGateway("key"); // Error: Cannot create an instance of an abstract class.
const stripe = new StripeGateway("sk_test_123");
stripe.processPayment(100);
2. Antarmuka: Mendefinisikan Kontrak untuk Perilaku
Antarmuka dalam TypeScript adalah cara untuk mendefinisikan kontrak untuk bentuk kelas. Mereka menentukan properti dan metode apa yang harus dimiliki kelas, tetapi mereka tidak memberikan implementasi apa pun. Sebuah kelas dapat `implement` beberapa antarmuka, menjadikannya landasan desain komposisional dan terpisah.
Antarmuka vs. Kelas Abstrak
- Gunakan kelas abstrak ketika Anda ingin berbagi kode yang diimplementasikan di antara beberapa kelas terkait erat.
- Gunakan antarmuka ketika Anda ingin mendefinisikan kontrak untuk perilaku yang dapat diimplementasikan oleh kelas yang berbeda dan tidak terkait.
Kasus Penggunaan: Dalam suatu sistem, banyak objek yang berbeda mungkin perlu diserialisasikan ke format string (misalnya, untuk pencatatan atau penyimpanan). Objek-objek ini (`User`, `Product`, `Order`) tidak terkait tetapi berbagi kemampuan umum.
interface ISerializable {
serialize(): string;
}
class User implements ISerializable {
constructor(public id: number, public name: string) {}
serialize(): string {
return JSON.stringify({ id: this.id, name: this.name });
}
}
class Product implements ISerializable {
constructor(public sku: string, public price: number) {}
serialize(): string {
return JSON.stringify({ sku: this.sku, price: this.price });
}
}
function logItems(items: ISerializable[]): void {
items.forEach(item => {
console.log("Serialized item:", item.serialize());
});
}
const user = new User(1, "Alice");
const product = new Product("TSHIRT-RED", 19.99);
logItems([user, product]);
3. Mixin: Pendekatan Komposisional untuk Penggunaan Kembali Kode
Karena TypeScript hanya memungkinkan pewarisan tunggal, bagaimana jika Anda ingin menggunakan kembali kode dari beberapa sumber? Di sinilah pola mixin masuk. Mixin adalah fungsi yang mengambil konstruktor dan mengembalikan konstruktor baru yang memperluasnya dengan fungsionalitas baru. Ini adalah bentuk komposisi yang memungkinkan Anda untuk "mencampurkan" kemampuan ke dalam kelas.
Kasus Penggunaan: Anda ingin menambahkan perilaku `Timestamp` (dengan `createdAt`, `updatedAt`) dan `SoftDeletable` (dengan properti `deletedAt` dan metode `softDelete()`) ke beberapa kelas model.
// A Type helper for mixins
type Constructor = new (...args: any[]) => T;
// Timestamp Mixin
function Timestamped(Base: TBase) {
return class extends Base {
createdAt: Date = new Date();
updatedAt: Date = new Date();
};
}
// SoftDeletable Mixin
function SoftDeletable(Base: TBase) {
return class extends Base {
deletedAt: Date | null = null;
softDelete() {
this.deletedAt = new Date();
console.log("Item has been soft deleted.");
}
};
}
// Base class
class DocumentModel {
constructor(public title: string) {}
}
// Create a new class by composing mixins
const UserAccountModel = SoftDeletable(Timestamped(DocumentModel));
const userAccount = new UserAccountModel("My User Account");
console.log(userAccount.title);
console.log(userAccount.createdAt);
userAccount.softDelete();
console.log(userAccount.deletedAt);
Kesimpulan: Membangun Aplikasi TypeScript yang Tahan Masa Depan
Menguasai Pemrograman Berorientasi Objek di TypeScript adalah perjalanan dari memahami sintaks hingga merangkul filosofi desain. Pilihan yang Anda buat mengenai struktur kelas, pewarisan, dan komposisi memiliki dampak besar pada kesehatan aplikasi Anda dalam jangka panjang.
Berikut adalah poin-poin penting untuk praktik pengembangan global Anda:
- Mulailah dengan Pilar: Pastikan Anda memiliki pemahaman yang kuat tentang Enkapsulasi, Abstraksi, Pewarisan, dan Polimorfisme. Mereka adalah kosakata OOP.
- Mendukung Komposisi Daripada Pewarisan: Prinsip ini akan mengarahkan Anda ke kode yang lebih fleksibel, modular, dan dapat diuji. Mulailah dengan komposisi dan hanya jangkau pewarisan ketika ada hubungan "is-a" yang jelas dan stabil.
- Gunakan Alat yang Tepat untuk Pekerjaan Itu:
- Gunakan Pewarisan untuk spesialisasi sejati dan berbagi kode dalam hierarki yang stabil.
- Gunakan Kelas Abstrak untuk mendefinisikan basis umum untuk keluarga kelas, berbagi beberapa implementasi sambil menegakkan kontrak.
- Gunakan Antarmuka untuk mendefinisikan kontrak untuk perilaku yang dapat diimplementasikan oleh kelas mana pun, mempromosikan pemisahan ekstrem.
- Gunakan Mixin saat Anda perlu menyusun fungsionalitas ke dalam kelas dari berbagai sumber, mengatasi keterbatasan pewarisan tunggal.
Dengan berpikir kritis tentang pola-pola ini dan memahami pertukaran mereka, Anda dapat merancang aplikasi TypeScript yang tidak hanya kuat dan efisien hari ini tetapi juga mudah diadaptasi, diperluas, dan dipelihara selama bertahun-tahun yang akan datang—di mana pun Anda atau tim Anda berada di dunia.