Tipe yang makin canggih menimbulkan biaya yang makin terasa, yaitu waktu tunggu compiler dan pesan error yang sulit dibaca. Level tipe pun tidak bebas biaya, karena seluruh perhitungannya dikerjakan compiler atas setiap berkas yang disunting. Topik ini membahas biaya pemeriksaan tipe yang kompleks, batas instantiation yang memunculkan pesan excessively deep, godaan type-golf, serta alternatif pragmatis yang tetap dapat dikelola tim.
Biaya pemeriksaan tipe yang kompleks#
Setiap pemakaian tipe generik membentuk satu instantiation, yakni satu contoh tipe yang dihitung dari parameternya, dan jumlah instantiation tumbuh cepat pada tipe yang bersarang. Mapped type atas antarmuka besar membentuk satu instantiation per kunci, sedangkan tipe rekursif membentuk rantai instantiation sepanjang kedalaman datanya. Compiler mengerjakan semuanya pada setiap penyuntingan, sehingga biayanya muncul sebagai keterlambatan hover, penyelesaian otomatis, dan perintah tsc pada CI.
Biaya tersebut wajar dibayar bila tipe itu mencegah error nyata, misalnya varian mendalam yang dipakai lintas berkas. Biaya yang sama menjadi boros bila tipe semacam itu hanya dipakai sekali untuk data yang bentuknya sudah jelas. Batas kedalaman instantiation bersifat protektif, karena tanpa batas, satu tipe yang bermasalah dapat menggantung seluruh proses pemeriksaan. Perbandingan berikut merangkum pertimbangan yang lazim dipakai sebelum menulis tipe bertingkat.
| Pendekatan | Keterbacaan | Biaya pemeriksaan | Cocok untuk |
|---|---|---|---|
| Tipe eksplisit dengan nama jelas | Tinggi | Paling rendah | Bentuk data yang tetap |
| Mapped type sederhana | Sedang | Rendah | Varian atas satu tipe sumber |
| Conditional type berlapis | Rendah | Menengah | Pemilihan bentuk bersyarat |
| Tipe rekursif mendalam | Paling sulit | Tertinggi | Data yang kedalamannya tak tetap |
Tabel di atas bukan larangan, melainkan urutan pertimbangan, yaitu mulai dari yang paling sederhana dan naik tingkat hanya bila kebutuhannya nyata. Proyek yang sama dapat memuat semuanya tanpa masalah selama masing-masing dipakai pada tempatnya. Bagian berikut memperlihatkan batas keras yang dihadapi pendekatan yang paling berat.
Batas instantiation dan pesan errornya#
Compiler menetapkan batas kedalaman instantiation, dan tipe yang melewatinya dihentikan dengan error TS2589 yang menyatakan instantiation terlalu dalam dan mungkin tak berujung. Pemicu yang paling sering adalah rekursi yang tidak pernah menyempit, misalnya rekursi string yang menambah karakter pada setiap langkah tanpa kasus dasar yang dapat dicapai. Berkas berikut sengaja memuat rekursi semacam itu.
// tidak-dijalankan
type Tumbuh<S extends string> =
S extends `${infer A}${infer B}` ? Tumbuh<`${S}x`> : S;
type Uji = Tumbuh<"a">;
const cek: { res: Uji } = { res: "berhenti" };
console.log(cek);# berkas ini sengaja tidak lolos pemeriksaan tipe
tsc --noEmit --pretty false rekursi-tak-berujung.tsrekursi-tak-berujung.ts(5,12): error TS2589: Type instantiation is excessively deep and possibly infinite.Tipe Tumbuh mencocokkan S terhadap pola berisi dua variabel infer, lalu memanggil dirinya dengan string yang lebih panjang, sehingga tidak ada langkah yang mendekati kasus dasarnya. Compiler berhenti pada batas kedalaman dan melaporkan posisi pemanggilannya, bukan posisi definisinya. Pada proyek nyata, pesan ini lazim muncul ketika tipe rekursif diarahkan ke data yang membentuk siklus atau ketika cabang cadangannya belum tertulis. Bila pesan yang sama muncul pada tipe yang sebenarnya akan berhenti, kedalaman rekursinya dikurangi atau definisinya dipecah menjadi beberapa helper yang lebih kecil.
Rekursi dengan batas kedalaman#
Versi berbatas dari rekursi tersebut membawa parameter tambahan berupa tuple yang bertambah satu anggota setiap langkah, sehingga D["length"] menghitung sisa bahan bakarnya. Ketika panjang tuple mencapai batas yang dipilih, kondisi pertama menghentikan rekursi dan meneruskan tipe yang ada. Parameter batas tidak perlu diisi pemanggil, karena nilai bawaannya berupa tuple kosong yang siap dihitung sejak pemanggilan pertama. Rekursi yang dipanggil pada posisi ekor seperti ini juga diberi jatah kedalaman lebih besar oleh compiler sejak TypeScript 4.5, sehingga pola ini sekaligus meringankan pemeriksaan.
type Expect<T extends true> = T;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
type UlangBatas<S extends string, D extends readonly unknown[] = []> =
D["length"] extends 5 ? S : UlangBatas<`${S}x`, [...D, unknown]>;
type Hasil = UlangBatas<"a">;
const b1: Expect<Equal<Hasil, "axxxxx">> = true;
const teks: Hasil = "axxxxx";
console.log(b1, teks);true axxxxxHasil UlangBatas<"a"> adalah literal "axxxxx", yaitu huruf awal ditambah lima langkah pengulangan sesuai batas yang dipilih. Bukti Equal menegaskan hasil itu, dan variabel teks membuktikan tipe literalnya dapat dipakai sebagai anotasi. Batas lima dipilih untuk contoh, sedangkan pemakaian nyata biasanya menyetel nilai yang lebih besar sesuai kedalaman data yang mungkin muncul. Batas yang disetel membuat tipe dapat diprediksi, sebab kedalaman maksimumnya tertulis pada definisinya, bukan ditentukan oleh bentuk data yang kebetulan diterima.
Type-golf dan keterbacaan tim#
Type-golf adalah praktik memadatkan definisi tipe ke bentuk terpendek yang mungkin, biasanya dengan menggabungkan conditional, mapped, dan rekursi dalam satu ekspresi. Definisi semacam itu pantas dipamerkan pada kompetisi penulisan tipe, tetapi menimbulkan biaya tim yang nyata, yaitu review yang lebih lama, pesan error yang penuh sisa instantiation, dan anggota baru yang harus membongkar definisi untuk memahami bentuk datanya. Biaya tersebut jarang terlihat saat definisi ditulis pertama kali, lalu baru terasa ketika definisi harus diubah atau diselidiki.
Selisihnya terlihat pada contoh yang sudah dikerjakan modul ini. Definisi satu baris type D<T> = T extends object ? { [K in keyof T]?: D<T[K]> } : T memang ringkas, tetapi huruf D tidak menjelaskan apa pun dan kedalaman rekursinya tak terkendali. Versi bertahap yang diberi nama DalamOpsional beserta parameter batas D tetap pendek, tetapi dapat dibaca ulang enam bulan kemudian tanpa membongkar logikanya dari awal.
Aturan praktisnya, nama yang menjelaskan maksud lebih berharga daripada baris yang dihemat. Helper yang dipecah menjadi beberapa definisi kecil menghasilkan pesan error yang menyebut nama helper itu, sehingga lokasi masalah lebih mudah ditemukan. Kode level tipe dibaca lebih sering daripada ditulis, dan pertimbangan itu berlaku sama seperti pada kode biasa.
Alternatif yang pragmatis#
Tiga alternatif berikut menjawab kebutuhan yang biasanya memancing tipe berlebihan. Pertama, tipe sederhana dengan komentar, yaitu menulis bentuk data secara eksplisit dan menjelaskan kendalanya dalam satu kalimat komentar. Kedua, helper bertahap, yaitu memecah tipe besar menjadi beberapa definisi bernama yang masing-masing mudah dipahami. Ketiga, unit test tipe, yaitu pemeriksaan Expect dan Equal yang mengunci perilaku sebuah utility type terhadap perubahan compiler.
type Expect<T extends true> = T;
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false;
type OmitKu<T, K extends keyof T> = { [P in Exclude<keyof T, K>]: T[P] };
type Pengukuran = { titik: string; suhu: number; kelembapan: number };
const uji1: Expect<Equal<OmitKu<Pengukuran, "titik">, { suhu: number; kelembapan: number }>> = true;
const uji2: Expect<Equal<OmitKu<Pengukuran, "titik" | "suhu">, { kelembapan: number }>> = true;
console.log(uji1, uji2);true trueDua pemeriksaan di atas mengunci hasil OmitKu untuk kasus satu kunci dan dua kunci. Bila compiler atau definisi berubah sehingga hasilnya menyimpang, pemeriksaan tipe gagal sebelum kode lain terpengaruh. Uji semacam ini berbiaya rendah karena dikerjakan compiler yang memang sudah berjalan, dan tidak membutuhkan kerangka pengujian tambahan.
Ukuran berhentinya cukup jelas, yaitu tipe berhenti ditingkatkan ketika ia sudah menangkap aturan yang diinginkan dan sisanya hanya memperhalus kasus yang belum pernah terjadi. Tipe yang dipakai lintas berkas atau diterbitkan dalam pustaka layak diberi rekursi dan uji tipe. Tipe sekali pakai untuk data yang bentuknya tetap biasanya cukup dengan tipe eksplisit.
Ringkasan#
- Biaya level tipe berupa instantiation yang dihitung compiler pada setiap penyuntingan, dan jumlahnya tumbuh seiring kedalaman tipe.
- Rekursi yang tidak pernah menyempit dihentikan compiler dengan error TS2589, dan perbaikannya adalah kasus dasar atau batas kedalaman.
- Penghitung kedalaman berbasis panjang tuple menjadikan rekursi berbatas sekaligus memanfaatkan eliminasi rekursi ekor.
- Helper bernama, tipe sederhana berkomentar, dan unit test tipe menutup sebagian besar kebutuhan tanpa type-golf.
Referensi#
- Conditional Types — The TypeScript Handbook — Dasar conditional type yang menjadi penyusun tipe rekursif pada topik ini.
- Mapped Types — The TypeScript Handbook — Sintaks pemetaan yang dipakai pada contoh transformasi bertingkat.
- Template Literal Types — The TypeScript Handbook
— Pola string yang dipakai rekursi
TumbuhdanUlangBatasuntuk menelusuri tipe teks.