Sebuah fungsi meminta timeout dalam millisecond. Anda meneruskan 5000. Nanti, fungsi lain meminta timeout dalam second. Anda meneruskan 5. Di antaranya, Anda memanggil setTimeout(duration, callback) dan tidak terjadi apa-apa selama satu jam dua puluh tiga menit.
TypeScript tidak menyelamatkan Anda di sini. 5000 dan 5 keduanya adalah number. Compiler tidak melihat perbedaan antara jarak dalam meter dan jarak dalam kaki, suhu dalam Celsius dan suhu dalam Fahrenheit, timestamp dan durasi. Suite pengujian Anda mungkin juga tidak menangkapnya, karena matematikanya benar. Unitnya saja yang salah.
Solusinya adalah berhenti memperlakukan unit sebagai dokumentasi dan mulai memperlakukannya sebagai tipe.
Mengapa number adalah tipe yang salah untuk kuantitas fisik
TypeScript menggunakan structural typing. Dua objek kompatibel jika bentuknya cocok. Ini biasanya merupakan fitur, tetapi untuk primitive seperti number berarti semua angka dapat saling diganti. number adalah number adalah number.
Pemeriksaan runtime dapat menangkap kesalahan unit, tetapi mahal untuk dipelihara dan mudah untuk dilewati. Anda perlu memvalidasi setiap argumen fungsi, setiap respons API, setiap konstanta yang didefinisikan di file lain. Dalam praktiknya, tidak ada yang melakukan ini. Pemeriksanya menjadi komentar, dan komentar bisa berbohong.
Alternatifnya adalah menyandikan unit secara langsung ke dalam tipe. Pada saat kompilasi, Seconds dan Milliseconds menjadi tipe yang tidak kompatibel. Kalikan Meters dengan Meters dan Anda mendapatkan SquareMeters. Tambahkan Miles ke Kilometers dan compiler menolaknya. Pada runtime, nilainya masih hanya sebuah angka. Tidak ada objek wrapper, tidak ada validasi runtime, tidak ada biaya performa. Ini adalah zero-cost abstraction.
Cara tipe phantom mengubah angka menjadi unit bermerek
TypeScript tidak mendukung nominal typing untuk primitive, tetapi mendukung intersection type dan unique symbol. Anda dapat memberi merek pada primitive sehingga dua merek tidak kompatibel bahkan ketika nilai yang mendasarinya identik.
Berikut polanya:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
Properti __brand tidak ada pada runtime. Itu adalah tipe phantom. Itu hanya ada di sistem tipe. Tetapi itu cukup untuk membuat Meters dan Kilometers saling tidak kompatibel.
Anda tidak dapat secara tidak sengaja menetapkan number biasa ke tipe bermerek. Ini intinya. Anda harus secara eksplisit membuatnya, yang memaksa Anda untuk menyatakan unitnya.
Sistem unit yang berfungsi di TypeScript
Berikut implementasi minimal tetapi lengkap yang menangani konstruksi, konversi, dan aritmatika.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
Penggunaan:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
Error muncul di tempat bug diperkenalkan, bukan di tempat nilai akhirnya digunakan. Anda tidak perlu melacak velocity kembali melalui tiga file untuk menemukan bahwa seseorang meneruskan millisecond ke parameter second.
Menurunkan unit majemuk dari unit dasar
Pola ini dapat diskalakan ke unit majemuk. Alih-alih menulis MetersPerSecond secara manual, Anda dapat menurunkannya dari tipe dasar menggunakan konstruktor generik.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
Dalam praktiknya, Anda mungkin tidak memerlukan analisis dimensional penuh. Sebagian besar tim mengalami diminishing returns setelah sekitar selusin tipe unit. Tujuannya bukan untuk memodelkan fisika. Tujuannya adalah menghilangkan kategori bug yang paling mahal: yang mana matematikanya bekerja tetapi unitnya tidak.
Trade-off yang harus Anda ketahui
Tipe bermerek tidak gratis. Mereka mengorbankan ergonomi.
Setiap literal harus dibungkus dalam konstruktor. setTimeout(callback, 5000) menjadi setTimeout(callback, milliseconds(5000)). Itu lebih banyak mengetik. Jika tim Anda tidak konsisten tentang konstruktor, Anda akan mendapatkan cast tidak aman yang tersebar di seluruh codebase. Pola ini hanya berfungsi jika semua orang menggunakannya.
Inferensi tipe juga menjadi berisik. Metode array dan fungsi generik dapat memaparkan merek di pesan error. number[] biasa lebih mudah dibaca daripada (number & { readonly __brand: "Milliseconds" })[]. Anda mungkin memerlukan alias tipe untuk menjaga signature tetap terbaca.
Serialization adalah titik gesekan lain. JSON tidak memiliki konsep tipe bermerek. Ketika Anda mengirim nilai Meters melalui kabel, nilai tersebut tiba sebagai number biasa di sisi lain. Anda harus merekonstruksi merek di batasnya. Ini adalah tempat yang tepat untuk melakukannya, tetapi ini adalah kode tambahan.
Keterbatasan terbesar adalah bahwa ini adalah teknik khusus TypeScript. Jika sistem Anda menyertakan layanan Python, microservice Go, atau consumer JavaScript biasa, merek menghilang di batas bahasa. Anda masih memerlukan validasi runtime di tepi sistem. Tipe bermerek melindungi kode TypeScript internal. Mereka tidak menggantikan skema untuk data eksternal.
Cara memperkenalkan ini tanpa mengganggu tim Anda
Jangan beri merek setiap angka di codebase Anda. Mulailah dengan parameter yang telah menyebabkan incident nyata.
- Identifikasi tiga bug terakhir yang terkait unit di produksi. Cari millisecond vs second, mata uang dalam denominasi berbeda, latitude vs longitude, atau koordinat layar vs koordinat dokumen.
- Beri merek tipe spesifik tersebut. Tambahkan konstruktor dan fungsi konversi.
- Perbarui fungsi di mana nilai-nilai tersebut digunakan. Biarkan compiler memandu Anda.
- Tambahkan aturan lint yang melarang
numbermentah untuk parameter tersebut.
Jangan beri merek penghitung loop, index array, atau persentase. Itu tidak bermensi. Menambahkan merek di sana adalah upacara tanpa nilai.
Jika Anda bekerja dalam bahasa dengan nominal typing yang lebih kuat, Anda memiliki opsi yang lebih baik. Pengguna Rust harus melihat crate uom. F# dan OCaml memiliki unit of measure yang dibangun ke dalam compiler. Sistem tipe structural TypeScript membuat ini sebagai workaround, bukan fitur kelas satu. Tetapi workaround ini cukup baik untuk menangkap bug nyata.
FAQ
Apakah ini menambah overhead runtime?
Tidak. Merek adalah konstruksi khusus waktu kompilasi. Setelah kompilasi, meters(100) hanyalah angka 100. Tidak ada objek wrapper, tidak ada properti tambahan, tidak ada pemeriksaan runtime.
Bagaimana dengan perkalian dan pembagian?
Anda memerlukan fungsi eksplisit atau operator yang di-overload. TypeScript tidak mendukung operator overloading, jadi distance / time harus melalui fungsi per() atau speed(). Ini bertele-tele, tetapi ini juga alasan bug tertangkap.
Bisakah saya menggunakan ini dengan library pihak ketiga?
Hanya jika library menerima tipe bermerek Anda. Jika setTimeout mengharapkan number, Anda dapat meneruskan Milliseconds karena merek adalah intersection dengan number. Sebaliknya tidak berlaku. Jika library mengembalikan number, Anda harus secara eksplisit memberinya merek sebelum menggunakannya sebagai tipe unit.
Bagaimana cara saya menangani unit pecahan seperti HalfSeconds?
Gunakan unit dasar dan konstruktor. halfSeconds(1) mengembalikan Milliseconds(500). Jangan buat merek untuk setiap subdivisi. Jaga jumlah unit dasar tetap kecil.
Berhenti menulis nama unit dalam nama variabel
Menyebut variabel timeoutInMs adalah dokumentasi. Dokumentasi bergeser. Menyebutnya timeout: Milliseconds adalah tipe. Tipe ditegakkan.
Lain kali Anda men-debug masalah dan menyadari timeout meleset dengan faktor seribu, tanyakan pada diri sendiri apakah nama variabel sudah cukup. Tidak. Sandikan unit dalam tipe, biarkan compiler yang bekerja, dan berhenti mempercayai diri sendiri untuk mengingat apakah fungsi tertentu ini menginginkan second atau millisecond.