Teknik-teknik Type Narrowing pada TypeScript

Teknik-teknik Type Narrowing pada TypeScript

TypeScript memiliki operator-operator untuk digunakan pada tipe data, salah satunya adalah operator union (|). Operator union digunakan untuk mengombinasikan beberapa tipe data, misalnya:

function getUser(id: number | string) {
  // ...
}

getUser(42); // tidak ada TypeError
getUser('42'); // juga tidak ada TypeError

Sederhananya, operator union bisa dianalogikan dengan operator OR; const a: number | string; bisa dibaca "variabel a bisa bertipe number atau string." Tipe data yang dibangun dengan operator union disebut dengan union type.

Union type memberikan fleksibilitas dalam menyematkan tipe data. Namun, ketika bekerja dengan union type kita mungkin membutuhkan kepastian tipe data pada saat yang bersamaan. Simak contoh kode di bawah ini:

type Novel = {
  author: string;
  synopsis: string;
};

type TextBook = {
  author: string;
  subject: string;
}

Katakanlah kita ingin membuat fungsi untuk mengambil ringkasan data suatu buku, berisi nama penulis dan sinopsis jika ada.

type Summary = { author: string; synopsis?: string };

function getSummary(book: Novel | TextBook): Summary {
  return {
    author: book.author,
    synopsis: book.synopsis  // error
   };
}

Mungkin Anda akan merasa aneh. Kenapa terjadi error pada book.synopsis, tapi tidak pada book.author? Untuk memahami ini, kita perlu memahami sistem Structural Typing yang dimiliki TypeScript.

Structural Typing

Sistem tipe data yang dianut TypeScript adalah Structural Typing. Dalam Structural Typing, suatu tipe data dianggap ekuivalen dengan tipe data lainnya apabila ia memiliki struktur properti yang sama, atau merupakan superset dari struktur properti tipe data pembandingnya.

type Square = {
  length: number;
};

type Line = {
  length: number;
};

type Rectangle = {
  length: number;
  width: number;
};

const l: Line = { length: 5 };
const s: Square = l; // Line dapat disematkan ke Square, karena strukturnya sama

const r: Rectangle = { length: 2; width: 3 };
const q: Square = r; // Rectangle bisa disematkan ke Square, karena struktur Rectangle adalah superset dari Square

Sebagai kontras, dalam bahasa pemrograman dengan sistem tipe data Nominal Typing, ekuivalensi dan kompatibilitas antartipe data ditentukan dari deklarasi eksplisitnya. Contohnya dapat dilihat pada potongan kode Java berikut ini:

class Foo {
  public String name;
}

class Bar {
  public String name;
}

Foo n = new Bar(); //error: incompatible types: Bar cannot be converted to Foo

Kembali ke contoh pertama kita, kenapa dalam tipe Novel | TextBook, akses ke properti author tidak error sementara synopsis error?

Yang diketahui TypeScript adalah book bisa bertipe Novel atau TextBook. Tetapi, TypeScript tidak tahu tipe pasti book pada saat properti synopsis diakses. Bisa jadi ia punya properti synopsis, bisa juga tidak.

Namun, TypeScript mengetahui baik Novel dan TextBook memiliki properti author bertipe string. Jadi, akses ke properti author selalu aman, manapun tipe data book pada saat properti itu diakses.

Dalam contoh kasus ini, kita ingin fungsi getSummary dapat beroperasi dengan parameter buku bertipe apa saja. Namun, di saat bersamaan kita perlu mengakses properti synopsis hanya jika tipe book bisa dipastikan Novel. Kita bisa mengandalkan fitur Type Narrowing dari TypeScript untuk mencapainya.

Type Narrowing dan Type Guard

TypeScript di balik layar dapat melakukan proses pengerucutan tipe data menjadi lebih spesifik daripada tipe yang dideklarasikan. Proses ini disebut Type Narrowing. TypeScript menganalisis kode yang kita tulis dan mencari keberadaan pola-pola khusus yang membantu inferensi tipe data paling spesifik pada suatu blok kode. Pola-pola khusus tersebut disebut Type Guard.

Terdapat banyak pola type guard yang bisa kita gunakan untuk memecahkan dilema pada contoh kode kita di atas. Mari kita telaah beberapa pola yang paling sering digunakan, antara lain operator in, discriminated union, fungsi type guard kustom dengan type predicate, dan operator typeof.

Operator in

Pertama-tama, mari kita coba gunakan in type guard. in adalah operator JavaScript yang berfungsi memeriksa keberadaan suatu properti dalam suatu instance objek. Ia akan mengembalikan true jika properti tersebut ditemukan.

Jika kita terapkan pada fungsi getSummary kita, begini jadinya:

function getSummary(book: Novel | TextBook): Summary {
  if ('synopsis' in book) {
    return { author: book.author, synopsis: book.synopsis };
  }

  return { author: book.author };
}

Pada blok if ('synopsis' in book) { // ... }, TypeScript menganalisis bahwa di antara tipe data Novel | TextBook, yang memiliki properti synopsis hanyalah Novel. Maka, TypeScript bisa dengan yakin menginferensi bahwa book memiliki tipe Novel pada blok tersebut.

Kemudian, TypeScript juga mendeteksi bahwa di blok tersebut terdapat pernyataan return. TypeScript mengerti bahwa pada blok setelahnya berarti book tidak memiliki synopsis, sehingga bisa diinferensikan bahwa book adalah TextBook.

Discriminated Union

Discriminated union adalah union type yang memiliki diskriminan yang dapat digunakan untuk type narrowing. Diskriminan adalah properti non-opsional yang ada di semua anggota union type, yang memiliki tipe data literal yang berbeda-beda antaranggotanya.

Tipe data literal (literal type) adalah nilai eksak yang digunakan sebagai tipe data, misalnya type Apple = 'apple'. Nilai-nilai tipe data string, number, atau boolean bisa digunakan sebagai tipe data literal.

Untuk contoh kasus kita, kita bisa menambahkan diskriminan pada masing-masing tipe:

type Novel = {
  kind: 'NOVEL';
  author: string;
  synopsis: string;
};

type TextBook = {
  kind: 'TEXT_BOOK';
  author: string;
  subject: string;
}

type Summary = { author: string; synopsis?: string };

function getSummary(book: Novel | TextBook): Summary {
  if (book.kind === 'NOVEL') {
    return { author: book.author, synopsis: book.synopsis };
  }

  return { author: book.author };
}

Pola ini membuat tipe data lebih eksplisit, namun kekurangannya kita jadi harus maintain diskriminan pada setiap anggota union.

Operator typeof

Cara selanjutnya adalah menggunakan operator JavaScript lain, yaitu typeof. Operator typeof sebagai type guard bisa dipertimbangkan jika terdapat properti dengan nama sama namun dengan tipe data berbeda di seluruh anggota union type.

Operator typeof mengembalikan nama tipe data suatu variabel. Karena pengecekannya dilakukan terhadap tipe data, maka operator ini bisa digunakan oleh TypeScript untuk type narrowing.

Katakanlah kita memiliki fungsi printAuthor yang mencetak nama atau nama-nama penulis suatu buku.

function printAuthor(author: string | string[]): string {
  if (typeof author === 'string') {
    return `The author is ${author}.`;
  } else {
    return `The authors are ${author.join(',')}`;
  }
}

Pada blok else { // ... }, TypeScript mengetahui bahwa tipe data author adalah string[], jadi operasi array .join() dimungkinkan di situ.

Kembali ke contoh pertama kita, katakanlah TextBook bisa memiliki banyak penulis, seperti ini:

type Novel = {
  author: string;
  synopsis: string;
};

type TextBook = {
  author: string[];
  subject: string;
}

type Summary = { author: string; synopsis?: string };

Ketika kita coba gunakan typeof untuk type narrowing...

function getSummary(book: Novel | TextBook): Summary {
  if (typeof book.author === 'string') {
    return {
      author: book.author,
      synopsis: book.synopsis // error
    };
  }

  return { author: book.author.join(',') };
}

Kita tetap mendapatkan error di book.synopsis! Tapi kita tidak mendapatkan error pada book.author.join(',')! Kok bisa?

Dengan melakukan typeof book.author === 'string', TypeScript hanya melakukan type narrowing terhadap book.author. TypeScript tidak menginferensikannya ke objek induknya, yaitu book. Dalam kasus seperti ini, kita bisa implemen type guard kita sendiri dengan bantuan Type Predicate.

Custom Type Guard dengan Type Predicate

Type Predicate adalah fitur pada TypeScript untuk melakukan assertion terhadap suatu tipe. Sintaks type predicate adalah parameter is type, misalnya book is Novel. Type predicate hanya dapat digunakan sebagai tipe data kembalian fungsi, yang membuat fungsi itu sendiri menjadi type guard terhadap parameternya.

Untuk kasus kita, kita bisa membuat fungsi isNovel sebagai berikut:

function isNovel(book: Novel | TextBook): book is Novel {
  return typeof book.author === 'string';
}

Dengan fungsi tersebut kita memberitahu TypeScript, jika isNovel dipanggil dengan suatu objek book mengembalikan nilai true, maka book bisa dipastikan bertipe Novel. Mari kita gunakan fungsi custom type guard kita itu dalam getSummary.

type Novel = {
  author: string;
  synopsis: string;
};

type TextBook = {
  author: string[];
  subject: string;
}

type Summary = { author: string; synopsis?: string };

function isNovel(book: Novel | TextBook): book is Novel {
  return typeof book.author === 'string';
}

function getSummary(book: Novel | TextBook): Summary {
  if (isNovel(book)) {
    return {
      author: book.author,
      synopsis: book.synopsis
    };
  }

  return { author: book.author.join(',') };
}

Sekarang, TypeScript bisa dengan presisi menerka tipe data book pada setiap blok.


Terdapat lebih banyak lagi teknik type narrowing dalam TypeScript, seperti operator instanceof, tipe data never, equality narrowing, dan lainnya. Namun, teknik-teknik yang diulas di sini adalah beberapa teknik yang saya rasa paling banyak digunakan dan paling praktis dalam keseharian menulis kode TypeScript.

Dalam bekerja dengan tipe data yang kompleks di TypeScript, seringkali type narrowing menjadi kunci memecah kebuntuan. Aplikasi teknik-teknik type narrowing akan lebih aman dan robust daripada sekadar menyematkan type casting dengan as, atau menggunakan any dengan serampangan.

Terima kasih sudah membaca, semoga bermanfaat!


Referensi dan Bacaan Lanjutan