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!