Kuartal lalu, kami mengirimkan bug yang melakukan refund pada pelanggan yang salah. Fungsi processRefund menerima user ID di tempat ia mengharapkan order ID. String-nya terlihat identik, tes-nya lolos, dan TypeScript tidak mengajukan keberatan sama sekali.
Kedua identifier diberi tipe string. TypeScript tidak melihat perbedaan di antara keduanya. Jika Anda pernah memasukkan userId ke parameter yang membutuhkan orderId dan menyaksikan compiler mengangkat bahu, Anda telah menabrak tembok yang sama.
Type alias tidak memperbaiki ini. Menamai variabel dengan hati-hati juga tidak. Compiler melakukan persis apa yang Anda perintahkan, dan itulah masalahnya.
Mengapa type UserId = string itu bohong
TypeScript menggunakan structural typing. Dua tipe kompatibel jika strukturnya cocok. Saat Anda menulis type UserId = string, Anda belum membuat tipe baru. Anda telah membuat alias. Saat compile time, UserId dan string identik. Begitu juga UserId dan OrderId.
type UserId = string;
type OrderId = string;
function fetchOrder(id: OrderId) {
// ...
}
const userId: UserId = "usr_0192";
fetchOrder(userId); // Compiles. Oops.
Compiler menghapus type alias selama type checking. Nama UserId untuk manusia. Type checker mengabaikannya. Ini biasanya merupakan fitur. Ia memungkinkan Anda menukar implementasi tanpa keributan. Tapi untuk identifier yang tidak boleh dipertukarkan, ini adalah footgun.
Branded types membuat struktur identik menjadi tidak kompatibel
Solusinya adalah branded type, kadang disebut opaque type atau newtype. Anda menginterseksikan tipe dasar dengan brand unik yang hanya ada di level tipe.
Saat runtime, nilainya masih sekadar string. Saat compile time, brand membuatnya berbeda dari string lainnya.
Berikut pola menggunakan unique symbol:
declare const UserIdBrand: unique symbol;
type UserId = string & { readonly [UserIdBrand]: void };
declare const OrderIdBrand: unique symbol;
type OrderId = string & { readonly [OrderIdBrand]: void };
unique symbol menjamin tidak ada tipe lain yang secara tidak sengaja membagikan brand ini. Properti readonly berarti Anda tidak bisa memutasinya. Saat runtime, properti symbol tidak ada di string yang sebenarnya, jadi ini murni konstruksi compile-time.
Untuk membuat nilai, Anda membutuhkan constructor function yang melakukan cast pada string mentah:
function UserId(value: string): UserId {
return value as UserId;
}
function OrderId(value: string): OrderId {
return value as OrderId;
}
Sekarang bug sebelumnya tertangkap bahkan sebelum Anda menjalankan kode:
const uid = UserId("usr_0192");
fetchOrder(uid);
// ^^^
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
Pesan error-nya jelas. Tipenya secara struktural berbeda karena brand-nya berbeda. Compiler tidak akan membiarkan Anda mencampuradukkannya.
Boilerplate-nya menyebalkan. Berikut helper-nya.
Menulis brand dan constructor untuk setiap tipe ID cepat membosankan. Kami menggunakan utility kecil yang menghasilkan keduanya:
interface Brand<T> {
readonly __brand: T;
}
type Branded<T, B> = T & Brand<B>;
function makeBrand<T, B>(
_brand: B
): (value: T) => Branded<T, B> {
return (value) => value as Branded<T, B>;
}
Penggunaan:
type UserId = Branded<string, "UserId">;
const UserId = makeBrand<string, "UserId">("UserId");
type OrderId = Branded<string, "OrderId">;
const OrderId = makeBrand<string, "OrderId">("OrderId");
Ini membuat deklarasi tetap dua baris per identifier. Anda juga bisa menggunakan pendekatan unique symbol dalam helper generik jika Anda lebih menyukai ketahanan collision yang lebih kuat. Bagaimanapun, tujuannya sama: membuat biaya menambahkan branded type baru cukup rendah sehingga Anda benar-benar melakukannya.
Bagaimana dengan object dan number?
Branded types bekerja untuk primitive apa pun, tidak hanya string. Kami memberi brand pada numeric ID dengan cara yang sama:
type DbUserId = Branded<number, "DbUserId">;
const DbUserId = makeBrand<number, "DbUserId">("DbUserId");
Anda juga bisa memberi brand pada object. Jika Anda memiliki dua konfigurasi yang memiliki shape sama tapi tidak boleh ditukar, beri brand pada mereka:
type ApiConfig = Branded<{
endpoint: string;
timeout: number;
}, "ApiConfig">;
Tapi hati-hati dengan object branding. Object saat runtime tidak akan memiliki properti brand, jadi JSON.stringify dan operasi spread berperilaku normal. Itu biasanya yang Anda inginkan. Tapi jika Anda melakukan deep equality check atau memasukkan nilai ke library yang memeriksa tipe saat runtime, brand tidak akan ada untuk membantu Anda. Ini murni compile-time guard.
Trade-off-nya nyata, tapi kecil
Branded types menambah gesekan. Setiap ID sekarang membutuhkan constructor call. Anda tidak bisa inline string mentah ke fungsi yang mengharapkan branded type. Itulah intinya, tapi itu berarti lebih banyak kode.
Serialization adalah gotcha lain. Saat Anda JSON.stringify branded string, Anda mendapatkan string mentah kembali. Saat Anda mem-parsenya nanti, Anda kehilangan brand. Anda perlu menerapkan ulang constructor di batas-batas sistem. Kami biasanya melakukan ini di parser respons API dan database row mapper.
const raw = await db.query("SELECT id FROM users WHERE ...");
return raw.map((row) => UserId(row.id));
Branded types juga tidak membantu dengan runtime validation. Jika string-nya malformed, brand tidak akan menangkapnya. Anda masih membutuhkan zod, valibot, atau validasi manual di tepi sistem Anda. Brand menjamin type distinctness, bukan data correctness.
Kapan ini worth it, dan kapan ini noise
Kami memberi brand pada identifier yang melintasi batas module: user ID, organization ID, trace ID, span ID. Ini adalah nilai-nilai yang melewati paling banyak kode dan memiliki peluang tertinggi untuk disertakan dalam urutan yang salah.
Kami tidak memberi brand pada internal loop counter, variabel temp lokal, atau apa pun dengan scope lebih kecil dari fungsi. Overhead-nya tidak sebanding untuk nilai yang tidak pernah meninggalkan tempat kelahirannya.
Jika codebase Anda memiliki function signature dengan tiga parameter string berurutan, itu adalah sinyal kuat. Branded types mengubah call site itu dari tebak-tebakan menjadi sesuatu yang diperiksa compiler untuk Anda.
Satu titik awal yang praktis
Anda tidak perlu memberi brand pada setiap string di codebase Anda besok. Pilih identifier yang telah menyebabkan bug nyata. Tambahkan brand untuk itu. Perhatikan compiler menangkap kesalahan selama refactor berikutnya. Momen tunggal itu, ketika build error mencegah bug produksi, adalah saat constructor call ekstra mulai terasa murah.
Mulai dari satu batas module. Tambahkan brand UserId. Tambahkan brand OrderId. Perbarui database layer Anda untuk menerapkan constructor saat baris masuk. Lihat bagaimana rasanya. Jika ia menangkap satu argumen yang salah di code review, itu sudah membayar dirinya sendiri.