Bayangkan kamu punya sebuah tipe yang menggambarkan seorang user. Lalu kamu butuh versi kedua dari tipe itu, tapi semua field-nya jadi opsional. Setelah itu butuh versi ketiga lagi, kali ini semua field-nya read-only alias tidak bisa diubah. Kamu bisa saja menyalin tipenya dan mengeditnya satu per satu, tapi cara itu cepat melelahkan. Lebih buruk lagi, begitu seseorang mengubah salah satu salinan dan lupa menyesuaikan yang lain, kedua versi itu langsung tidak sinkron.
TypeScript punya jawaban yang jauh lebih rapi: mapped type. Mapped type adalah semacam resep yang mengambil sebuah tipe lalu membangun tipe baru dengan menelusuri tiap properti dan mengubahnya. Begitu kamu paham polanya, banyak “keajaiban” bawaan TypeScript yang tadinya terasa misterius jadi masuk akal. Di artikel ini kita akan jalan pelan-pelan, menyusun idenya dari potongan-potongan kecil, dan menutupnya dengan satu contoh lengkap yang bisa kamu telusuri baris demi baris.
Dua bahan yang perlu kamu kuasai dulu
Sebelum mapped type terasa masuk akal, ada dua perkakas kecil yang harus klik di kepala. Keduanya sederhana, jadi kita bereskan dulu.
keyof memberi kamu daftar key sebagai tipe
Ketika kamu menulis keyof SomeType, TypeScript mengembalikan sebuah tipe yang berisi seluruh nama properti (key) dari tipe itu, digabung dengan tanda | (dibaca “atau”).
type User = {
name: string;
email: string;
age: number;
};
type UserKeys = keyof User;
// UserKeys adalah: "name" | "email" | "age"
Jadi UserKeys bukan nilai yang bisa kamu jalankan, melainkan sebuah tipe yang artinya “salah satu dari ketiga teks ini”. Sebuah variabel bertipe UserKeys hanya boleh berisi "name", "email", atau "age".
Indexed access membaca tipe sebuah properti
Perkakas kedua bentuknya mirip mengakses elemen array, tapi ini bekerja pada tipe. User["email"] memberi kamu tipe dari properti email, bukan nilainya.
type EmailType = User["email"]; // string
type AgeType = User["age"]; // number
Kamu bahkan bisa menggabungkannya dengan keyof untuk mengambil tipe dari semua properti sekaligus:
type AnyValue = User[keyof User]; // string | number
Baris itu artinya “tipe dari nilai apa pun yang ada di balik key mana pun pada User”. Pegang dua ide ini baik-baik: keyof untuk mengambil key-nya, indexed access untuk mengambil tipe nilainya. Mapped type sebenarnya cuma keduanya bekerja bersama di dalam sebuah perulangan.
Bentuk dasar sebuah mapped type
Ini pola intinya. Baca pelan-pelan:
type Example = {
[K in keyof User]: User[K];
};
Mari kita bedah kata per kata:
[K in keyof User]adalah perulangannya. Artinya: untuk tiap key diUser, biarkanKmewakili key itu, satu per satu.: User[K]adalah hasil untuk tiap key. Untuk keyKyang sedang diproses, ambil tipenya dariUser.
Mapped type yang satu ini membangun ulang User persis seperti aslinya, tiap key mempertahankan tipe asalnya. Sendirian, ini memang belum berguna, tapi ia membuktikan mekanismenya: kita sudah mengunjungi tiap properti dan menentukan ia menjadi apa. Kekuatannya muncul begitu kita mengubah sisi kanan atau menambahkan sebuah modifier.
{ [K in keyof T]: <tipe baru untuk K> }
| | |
| | +-- key ini menjadi apa
| +------------- tipe sumber yang kita baca
+---------------------- variabel perulangan (satu key tiap putaran)
K cuma sekadar nama
Huruf K itu kebiasaan saja, singkatan dari “key”, tapi sebenarnya hanya nama variabel. Kamu bisa menulis [Prop in keyof User] dan hasilnya sama persis. Pilih nama mana pun yang paling enak dibaca.
Mengubah nilainya
Sekarang kita benar-benar mengubah sesuatu. Misalkan kamu ingin sebuah tipe yang setiap field-nya User menjadi boolean, bentuk yang umum dipakai untuk melacak field mana saja di sebuah form yang sudah disentuh pengguna.
type TouchedFields = {
[K in keyof User]: boolean;
};
// {
// name: boolean;
// email: boolean;
// age: boolean;
// }
Kita mempertahankan key-nya tapi membuang tipe nilai aslinya, menggantinya dengan boolean. Struktur User ikut terbawa secara otomatis. Kalau nanti seseorang menambahkan field phone: string ke User, TouchedFields langsung kebagian phone: boolean tanpa kita sentuh, karena ia dihasilkan, bukan disalin.
Supaya bisa dipakai ulang untuk tipe apa pun, bungkus dalam sebuah generic. T di sini adalah tempat kosong yang diisi saat tipe itu digunakan:
type AllBooleans<T> = {
[K in keyof T]: boolean;
};
type TouchedUser = AllBooleans<User>;
Sekarang AllBooleans bisa bekerja untuk sebuah produk, sebuah order, apa saja. Persis seperti inilah TypeScript menyusun perkakas bawaannya sendiri, seperti yang akan kita lihat sebentar lagi.
Modifier: menambah dan melepas optional serta readonly
Mapped type bisa lebih dari sekadar mengganti tipe nilai. Ia bisa menambah atau melepas dua modifier properti: tanda ? yang menandai properti sebagai opsional, dan readonly yang mencegah properti ditimpa ulang.
Untuk menambah sebuah modifier, tulis ia di depan key. Untuk melepas modifier yang sudah ada, beri tanda minus di depannya: -? atau -readonly.
// Buat semua properti jadi opsional
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Buat semua properti jadi wajib (lepas tanda ?)
type Required<T> = {
[K in keyof T]-?: T[K];
};
// Buat semua properti jadi read-only
type Frozen<T> = {
readonly [K in keyof T]: T[K];
};
Tanda ? setelah [K in keyof T] itulah seluruh rahasia di balik membuat field jadi opsional. Dipadukan dengan T[K] yang menjaga tipe nilai aslinya, kamu mendapat salinan “semua opsional” yang setia dari tipe apa pun.
Kamu sudah pernah memakainya
Kalau kamu pernah menulis Partial<User> atau Readonly<User>, sebenarnya kamu sudah memakai mapped type tanpa sadar. Keduanya bukan kata kunci khusus dari kompiler, melainkan tipe biasa yang didefinisikan di pustaka standar TypeScript memakai pola yang persis seperti di atas.
Bagaimana Partial dan Readonly sebenarnya dibuat
Bagian inilah yang menyatukan semuanya. Utility type bawaan yang kamu pakai tiap hari ternyata cuma mapped type pendek. Definisi aslinya pada dasarnya seperti ini:
type Partial<T> = {
[K in keyof T]?: T[K];
};
type Required<T> = {
[K in keyof T]-?: T[K];
};
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
Bandingkan tiap baris dengan pola yang sudah kita bangun:
| Utility | Yang dilakukan mapped type-nya |
|---|---|
Partial<T> |
Telusuri tiap key, tambahkan ? supaya jadi opsional |
Required<T> |
Telusuri tiap key, pakai -? untuk melepas opsional |
Readonly<T> |
Telusuri tiap key, tambahkan readonly ke masing-masing |
Tidak ada mekanisme tersembunyi. Begitu mapped type sudah masuk akal, definisi-definisi ini terbaca seperti kalimat biasa: “untuk tiap key, pertahankan tipenya, tapi tandai opsional.” Itulah inti dari mempelajari polanya, ia menyingkap sekaligus sebagian besar isi pustaka standar TypeScript.
Satu contoh lengkap, langkah demi langkah
Mari kita susun sesuatu yang sedikit lebih menarik dan telusuri tiap barisnya. Misalkan kita punya objek pengaturan, dan kita ingin sebuah tipe yang tiap field-nya berubah menjadi fungsi getter yang mengembalikan tipe nilai aslinya. Jadi theme: string mestinya berubah menjadi getTheme: () => string.
Kita butuh dua hal sekaligus: mengubah nilai (membungkusnya jadi fungsi) dan mengganti nama key (menambahkan get lalu mengawalinya dengan huruf besar). Penggantian nama memakai klausa as setelah perulangan, dipadukan dengan template literal type.
type Settings = {
theme: string;
fontSize: number;
notifications: boolean;
};
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type SettingsGetters = Getters<Settings>;
// {
// getTheme: () => string;
// getFontSize: () => number;
// getNotifications: () => boolean;
// }
Sekarang mari kita urai baris tengah yang padat itu, [K in keyof T as ...]:
[K in keyof T— perulangan yang sudah akrab: kunjungi tiap key dariT, satu per satu, sebut iaK.as— memulai penggantian nama key. Apa pun yang mengikutinya menjadi nama key yang baru.`get${Capitalize<string & K>}`— sebuah template literal type. Ia menempelkan teksgetdi depan key.Capitalize<...>membuat huruf pertama jadi kapital, jadithememenjadiTheme. Bagianstring & Kadalah langkah pengaman kecil yang memberi tahu TypeScript untuk memperlakukanKsebagai string supayaCapitalizemau menerimanya.: () => T[K]— tipe nilai yang baru. Untuk key yang sedang diproses, hasilnya adalah fungsi tanpa argumen yang mengembalikan tipe asli key tersebut, yaituT[K].
Disatukan, tiap key seperti theme: string ditulis ulang menjadi getTheme: () => string. Key-nya berubah, nilainya berubah, dan kita cuma menulis aturannya satu kali. Tambahkan satu field ke Settings, getter yang sesuai muncul dengan sendirinya.
Penggantian nama key fitur yang lebih baru
Klausa as untuk mengganti nama key ditambahkan di versi TypeScript yang lebih baru dibanding mapped type dasar. Bentuk polos [K in keyof T] sudah ada jauh lebih lama. Kalau penggantian nama key memunculkan error yang tak terduga, periksa apakah versi TypeScript-mu sudah cukup baru, sebelum buru-buru menyimpulkan sintaksnya salah.
Kapan sebaiknya memakai mapped type
Mapped type bersinar ketika dua salinan dari sebuah tipe seharusnya tetap sinkron, tapi terpaksa kamu jaga manual. Beberapa pertanda jujurnya:
- Kamu terus-menerus menulis tipe yang nyaris identik dan cuma berbeda di
?,readonly, atau tipe nilai yang seragam. - Kamu ingin sebuah tipe memperbarui dirinya sendiri otomatis ketika tipe sumbernya bertambah atau berkurang field.
- Sebuah pustaka meminta “bentuk yang sama, tapi semua opsional” atau “key yang sama, tapi dipetakan ke handler”.
Dan satu pesan untuk menahan diri: kalau kamu cuma butuh satu tipe tetap yang tidak akan berubah, tulis saja apa adanya. Mapped type baru sepadan kalau ada pemakaian ulang dan kebutuhan untuk tetap sinkron dengan tipe sumber. Memaksakan versi yang canggih padahal objek biasa sudah cukup hanya membuat kode lebih sulit dibaca orang berikutnya.
Rangkuman
Mapped type adalah salah satu fitur yang kelihatannya menyeramkan padahal ternyata kecil begitu kamu melihat bagian-bagiannya. Ini seluruh idenya dalam beberapa baris:
keyof Tmemberi kamu gabungan key sebuah tipe, dalam bentuk tipe.T[K](indexed access) memberi kamu tipe dari sebuah properti.- Mapped type,
{ [K in keyof T]: ... }, menelusuri key-key itu dan menentukan masing-masing menjadi apa. - Modifier
?danreadonlybisa ditambahkan langsung, atau dilepas dengan-?dan-readonly. Partial,Required, danReadonlybawaan TypeScript hanyalah mapped type ini, tidak lebih.- Sebuah klausa
asdengan template literal type memungkinkan kamu mengganti nama key sambil mengubahnya.
Lain kali kamu melihat Partial<User> di sebuah proyek, kamu akan tahu persis apa yang terjadi di baliknya: sebuah perulangan kecil, mengunjungi tiap key, menambahkan tanda tanya. Mulailah dari utility bawaan, lalu tulis versimu sendiri pada hari ketika kamu mendapati dirimu menyalin sebuah tipe dan mengeditnya manual. Saat itulah sebuah mapped type membayar dirinya sendiri.