Bahasa Indonesia

Manfaatkan kekuatan anotasi varian dan batasan parameter tipe TypeScript untuk menciptakan kode yang lebih fleksibel, aman, dan mudah dipelihara. Sebuah pembahasan mendalam dengan contoh praktis.

Anotasi Varian TypeScript: Menguasai Batasan Parameter Tipe untuk Kode yang Tangguh

TypeScript, superset dari JavaScript, menyediakan pengetikan statis, yang meningkatkan keandalan dan kemudahan pemeliharaan kode. Salah satu fitur TypeScript yang lebih canggih namun kuat adalah dukungannya untuk anotasi varian yang bekerja sama dengan batasan parameter tipe. Memahami konsep-konsep ini sangat penting untuk menulis kode generik yang benar-benar tangguh dan fleksibel. Postingan blog ini akan mendalami varian, kovarian, kontravarian, dan invarian, menjelaskan cara menggunakan batasan parameter tipe secara efektif untuk membangun komponen yang lebih aman dan dapat digunakan kembali.

Memahami Varian

Varian menjelaskan bagaimana hubungan subtipe antara tipe memengaruhi hubungan subtipe antara tipe yang dibangun (misalnya, tipe generik). Mari kita uraikan istilah-istilah kuncinya:

Paling mudah diingat dengan analogi: Bayangkan sebuah pabrik yang membuat kalung anjing. Pabrik kovarian mungkin dapat memproduksi kalung untuk semua jenis hewan jika ia dapat memproduksi kalung untuk anjing, dengan mempertahankan hubungan subtipe. Pabrik kontravarian adalah pabrik yang dapat *mengkonsumsi* semua jenis kalung hewan, asalkan ia dapat mengkonsumsi kalung anjing. Jika pabrik hanya dapat bekerja dengan kalung anjing dan tidak ada yang lain, maka ia invarian terhadap jenis hewan.

Mengapa Varian Penting?

Memahami varian sangat penting untuk menulis kode yang aman secara tipe, terutama saat berhadapan dengan generik. Asumsi yang salah tentang kovarian atau kontravarian dapat menyebabkan kesalahan saat runtime yang dirancang untuk dicegah oleh sistem tipe TypeScript. Pertimbangkan contoh yang salah ini (dalam JavaScript, tetapi mengilustrasikan konsepnya):

// Contoh JavaScript (hanya untuk ilustrasi, BUKAN TypeScript)
function modifyAnimals(animals, modifier) {
  for (let i = 0; i < animals.length; i++) {
    animals[i] = modifier(animals[i]);
  }
}

function sound(animal) { return animal.sound(); }

function Cat(name) { this.name = name; this.sound = () => "Meow!"; }
Cat.prototype = Object.create({ sound: () => "Generic Animal Sound"});
function Animal(name) { this.name = name; this.sound = () => "Generic Animal Sound"; }

let cats = [new Cat("Whiskers"), new Cat("Mittens")];

//Kode ini akan menimbulkan galat karena menetapkan Animal ke array Cat tidak benar
//modifyAnimals(cats, (animal) => new Animal("Generic")); 

//Ini berfungsi karena Cat ditetapkan ke array Cat
modifyAnimals(cats, (cat) => new Cat("Fuzzy"));

//cats.forEach(cat => console.log(cat.sound()));

Meskipun contoh JavaScript ini secara langsung menunjukkan potensi masalah, sistem tipe TypeScript umumnya *mencegah* jenis penetapan langsung seperti ini. Pertimbangan varian menjadi penting dalam skenario yang lebih kompleks, terutama saat berhadapan dengan tipe fungsi dan antarmuka generik.

Batasan Parameter Tipe

Batasan parameter tipe memungkinkan Anda untuk membatasi tipe yang dapat digunakan sebagai argumen tipe dalam tipe dan fungsi generik. Mereka menyediakan cara untuk mengekspresikan hubungan antara tipe dan memberlakukan properti tertentu. Ini adalah mekanisme yang kuat untuk memastikan keamanan tipe dan memungkinkan inferensi tipe yang lebih presisi.

Kata Kunci extends

Cara utama untuk mendefinisikan batasan parameter tipe adalah dengan menggunakan kata kunci extends. Kata kunci ini menetapkan bahwa parameter tipe harus merupakan subtipe dari tipe tertentu.

function logName<T extends { name: string }>(obj: T): void {
  console.log(obj.name);
}

// Penggunaan yang valid
logName({ name: "Alice", age: 30 });

// Galat: Argumen tipe '{}' tidak dapat ditetapkan ke parameter tipe '{ name: string; }'.
// logName({});

Dalam contoh ini, parameter tipe T dibatasi menjadi tipe yang memiliki properti name dengan tipe string. Ini memastikan bahwa fungsi logName dapat dengan aman mengakses properti name dari argumennya.

Batasan Ganda dengan Tipe Persimpangan (Intersection Types)

Anda dapat menggabungkan beberapa batasan menggunakan tipe persimpangan (&). Ini memungkinkan Anda untuk menetapkan bahwa parameter tipe harus memenuhi beberapa kondisi.

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

function logPerson<T extends Named & Aged>(person: T): void {
  console.log(`Name: ${person.name}, Age: ${person.age}`);
}

// Penggunaan yang valid
logPerson({ name: "Bob", age: 40 });

// Galat: Argumen tipe '{ name: string; }' tidak dapat ditetapkan ke parameter tipe 'Named & Aged'.
// Properti 'age' tidak ada dalam tipe '{ name: string; }' tetapi diperlukan dalam tipe 'Aged'.
// logPerson({ name: "Charlie" });

Di sini, parameter tipe T dibatasi menjadi tipe yang merupakan Named dan Aged. Ini memastikan bahwa fungsi logPerson dapat dengan aman mengakses properti name dan age.

Menggunakan Batasan Tipe dengan Kelas Generik

Batasan tipe sama bergunanya saat bekerja dengan kelas generik.

interface Printable {
  print(): void;
}

class Document<T extends Printable> {
  content: T;

  constructor(content: T) {
    this.content = content;
  }

  printDocument(): void {
    this.content.print();
  }
}

class Invoice implements Printable {
  invoiceNumber: string;

  constructor(invoiceNumber: string) {
    this.invoiceNumber = invoiceNumber;
  }

  print(): void {
    console.log(`Mencetak faktur: ${this.invoiceNumber}`);
  }
}

const myInvoice = new Invoice("INV-2023-123");
const document = new Document(myInvoice);
document.printDocument(); // Output: Mencetak faktur: INV-2023-123

Dalam contoh ini, kelas Document bersifat generik, tetapi parameter tipe T dibatasi menjadi tipe yang mengimplementasikan antarmuka Printable. Ini menjamin bahwa setiap objek yang digunakan sebagai content dari Document akan memiliki metode print. Ini sangat berguna dalam konteks internasional di mana pencetakan mungkin melibatkan berbagai format atau bahasa, yang memerlukan antarmuka print yang umum.

Kovarian, Kontravarian, dan Invarian di TypeScript (Ditinjau Kembali)

Meskipun TypeScript tidak memiliki anotasi varian eksplisit (seperti in dan out di beberapa bahasa lain), ia secara implisit menangani varian berdasarkan bagaimana parameter tipe digunakan. Penting untuk memahami nuansa cara kerjanya, terutama dengan parameter fungsi.

Tipe Parameter Fungsi: Kontravarian

Tipe parameter fungsi bersifat kontravarian. Ini berarti Anda dapat dengan aman meneruskan fungsi yang menerima tipe yang lebih umum dari yang diharapkan. Ini karena jika sebuah fungsi dapat menangani Supertipe, ia pasti dapat menangani Subtipe.

interface Animal {
  name: string;
}

interface Cat extends Animal {
  meow(): void;
}

function feedAnimal(animal: Animal): void {
  console.log(`Memberi makan ${animal.name}`);
}

function feedCat(cat: Cat): void {
  console.log(`Memberi makan ${cat.name} (seekor kucing)`);
  cat.meow();
}

// Ini valid karena tipe parameter fungsi bersifat kontravarian
let feed: (animal: Animal) => void = feedCat; 

let genericAnimal:Animal = {name: "Hewan Generik"};

feed(genericAnimal); // Berfungsi tetapi tidak akan mengeong

let mittens: Cat = { name: "Mittens", meow: () => {console.log("Mittens mengeong");}};

feed(mittens); // Juga berfungsi, dan *mungkin* akan mengeong tergantung pada fungsi sebenarnya.

Dalam contoh ini, feedCat adalah subtipe dari (animal: Animal) => void. Ini karena feedCat menerima tipe yang lebih spesifik (Cat), membuatnya kontravarian sehubungan dengan tipe Animal dalam parameter fungsi. Bagian krusialnya adalah penetapan: let feed: (animal: Animal) => void = feedCat; adalah valid.

Tipe Kembalian (Return Types): Kovarian

Tipe kembalian fungsi bersifat kovarian. Ini berarti Anda dapat dengan aman mengembalikan tipe yang lebih spesifik dari yang diharapkan. Jika sebuah fungsi berjanji untuk mengembalikan Animal, mengembalikan Cat sangat dapat diterima.

function getAnimal(): Animal {
  return { name: "Hewan Generik" };
}

function getCat(): Cat {
  return { name: "Whiskers", meow: () => { console.log("Whiskers mengeong"); } };
}

// Ini valid karena tipe kembalian fungsi bersifat kovarian
let get: () => Animal = getCat;

let myAnimal: Animal = get();

console.log(myAnimal.name); // Berfungsi

// myAnimal.meow();  // Galat: Properti 'meow' tidak ada pada tipe 'Animal'.
// Anda perlu menggunakan penegasan tipe untuk mengakses properti spesifik Cat

if ((myAnimal as Cat).meow) {
  (myAnimal as Cat).meow(); // Whiskers mengeong
}

Di sini, getCat adalah subtipe dari () => Animal karena ia mengembalikan tipe yang lebih spesifik (Cat). Penetapan let get: () => Animal = getCat; adalah valid.

Array dan Generik: Invarian (Sebagian Besar)

TypeScript memperlakukan array dan sebagian besar tipe generik sebagai invarian secara default. Ini berarti Array<Cat> *tidak* dianggap sebagai subtipe dari Array<Animal>, bahkan jika Cat memperluas Animal. Ini adalah pilihan desain yang disengaja untuk mencegah potensi kesalahan saat runtime. Meskipun array *berperilaku* seperti kovarian di banyak bahasa lain, TypeScript membuatnya invarian demi keamanan.

let animals: Animal[] = [{ name: "Hewan Generik" }];
let cats: Cat[] = [{ name: "Whiskers", meow: () => { console.log("Whiskers mengeong"); } }];

// Galat: Tipe 'Cat[]' tidak dapat ditetapkan ke tipe 'Animal[]'.
// Tipe 'Cat' tidak dapat ditetapkan ke tipe 'Animal'.
// Properti 'meow' tidak ada dalam tipe 'Animal' tetapi diperlukan dalam tipe 'Cat'.
// animals = cats; // Ini akan menyebabkan masalah jika diizinkan!

//Namun ini akan berhasil
animals[0] = cats[0];

console.log(animals[0].name);

//animals[0].meow();  // galat - animals[0] dianggap sebagai tipe Animal sehingga meow tidak tersedia

(animals[0] as Cat).meow(); // Penegasan tipe diperlukan untuk menggunakan metode spesifik Cat

Mengizinkan penetapan animals = cats; akan tidak aman karena Anda kemudian dapat menambahkan Animal generik ke array animals, yang akan melanggar keamanan tipe dari array cats (yang seharusnya hanya berisi objek Cat). Karena itu, TypeScript menyimpulkan bahwa array bersifat invarian.

Contoh Praktis dan Kasus Penggunaan

Pola Repositori Generik

Pertimbangkan pola repositori generik untuk akses data. Anda mungkin memiliki tipe entitas dasar dan antarmuka repositori generik yang beroperasi pada tipe tersebut.

interface Entity {
  id: string;
}

interface Repository<T extends Entity> {
  getById(id: string): T | undefined;
  save(entity: T): void;
  delete(id: string): void;
}

class InMemoryRepository<T extends Entity> implements Repository<T> {
  private data: { [id: string]: T } = {};

  getById(id: string): T | undefined {
    return this.data[id];
  }

  save(entity: T): void {
    this.data[entity.id] = entity;
  }

  delete(id: string): void {
    delete this.data[id];
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepository: Repository<Product> = new InMemoryRepository<Product>();

const newProduct: Product = { id: "123", name: "Laptop", price: 1200 };
productRepository.save(newProduct);

const retrievedProduct = productRepository.getById("123");
if (retrievedProduct) {
  console.log(`Produk yang diambil: ${retrievedProduct.name}`);
}

Batasan tipe T extends Entity memastikan bahwa repositori hanya dapat beroperasi pada entitas yang memiliki properti id. Ini membantu menjaga integritas dan konsistensi data. Pola ini berguna untuk mengelola data dalam berbagai format, beradaptasi dengan internasionalisasi dengan menangani berbagai jenis mata uang dalam antarmuka Product.

Penanganan Event dengan Payload Generik

Kasus penggunaan umum lainnya adalah penanganan event. Anda dapat mendefinisikan tipe event generik dengan payload tertentu.

interface Event<T> {
  type: string;
  payload: T;
}

interface UserCreatedEventPayload {
  userId: string;
  email: string;
}

interface ProductPurchasedEventPayload {
  productId: string;
  quantity: number;
}

function handleEvent<T>(event: Event<T>): void {
  console.log(`Menangani event tipe: ${event.type}`);
  console.log(`Payload: ${JSON.stringify(event.payload)}`);
}

const userCreatedEvent: Event<UserCreatedEventPayload> = {
  type: "user.created",
  payload: { userId: "user123", email: "alice@example.com" },
};

const productPurchasedEvent: Event<ProductPurchasedEventPayload> = {
  type: "product.purchased",
  payload: { productId: "product456", quantity: 2 },
};

handleEvent(userCreatedEvent);
handleEvent(productPurchasedEvent);

Ini memungkinkan Anda untuk mendefinisikan berbagai tipe event dengan struktur payload yang berbeda, sambil tetap menjaga keamanan tipe. Struktur ini dapat dengan mudah diperluas untuk mendukung detail event yang dilokalkan, menggabungkan preferensi regional ke dalam payload event, seperti format tanggal yang berbeda atau deskripsi khusus bahasa.

Membangun Pipeline Transformasi Data Generik

Pertimbangkan skenario di mana Anda perlu mengubah data dari satu format ke format lain. Pipeline transformasi data generik dapat diimplementasikan menggunakan batasan parameter tipe untuk memastikan bahwa tipe input dan output kompatibel dengan fungsi transformasi.

interface DataTransformer<TInput, TOutput> {
  transform(input: TInput): TOutput;
}

function processData<TInput, TOutput, TIntermediate>(
  input: TInput,
  transformer1: DataTransformer<TInput, TIntermediate>,
  transformer2: DataTransformer<TIntermediate, TOutput>
): TOutput {
  const intermediateData = transformer1.transform(input);
  const outputData = transformer2.transform(intermediateData);
  return outputData;
}

interface RawUserData {
  firstName: string;
  lastName: string;
}

interface UserData {
  fullName: string;
  email: string;
}

class RawToIntermediateTransformer implements DataTransformer<RawUserData, {name: string}> {
    transform(input: RawUserData): {name: string} {
        return { name: `${input.firstName} ${input.lastName}`};
    }
}

class IntermediateToUserTransformer implements DataTransformer<{name: string}, UserData> {
    transform(input: {name: string}): UserData {
        return {fullName: input.name, email: `${input.name.replace(" ", ".")}@example.com`};
    }
}

const rawData: RawUserData = { firstName: "John", lastName: "Doe" };

const userData: UserData = processData(
  rawData,
  new RawToIntermediateTransformer(),
  new IntermediateToUserTransformer()
);

console.log(userData);

Dalam contoh ini, fungsi processData mengambil input, dua transformer, dan mengembalikan output yang telah ditransformasi. Parameter tipe dan batasannya memastikan bahwa output dari transformer pertama kompatibel dengan input dari transformer kedua, menciptakan pipeline yang aman secara tipe. Pola ini bisa sangat berharga saat berhadapan dengan kumpulan data internasional yang memiliki nama bidang atau struktur data yang berbeda, karena Anda dapat membangun transformer spesifik untuk setiap format.

Praktik Terbaik dan Pertimbangan

Kesimpulan

Menguasai anotasi varian TypeScript (secara implisit melalui aturan parameter fungsi) dan batasan parameter tipe sangat penting untuk membangun kode yang tangguh, fleksibel, dan mudah dipelihara. Dengan memahami konsep kovarian, kontravarian, dan invarian, serta dengan menggunakan batasan tipe secara efektif, Anda dapat menulis kode generik yang aman secara tipe dan dapat digunakan kembali. Teknik-teknik ini sangat berharga saat mengembangkan aplikasi yang perlu menangani berbagai tipe data atau beradaptasi dengan lingkungan yang berbeda, seperti yang umum terjadi dalam lanskap perangkat lunak global saat ini. Dengan mematuhi praktik terbaik dan menguji kode Anda secara menyeluruh, Anda dapat membuka potensi penuh dari sistem tipe TypeScript dan menciptakan perangkat lunak berkualitas tinggi.