TypeScript membandingkan tipe berdasarkan bentuknya, jadi dua alias dari string bisa saling menggantikan. Branded type menambahkan tag yang hanya ada di sistem tipe, string & { readonly __brand: "UserId" }, yang membuat UserId tidak kompatibel dengan string biasa maupun dengan brand lainnya:
Saat runtime, userId hanyalah string "u_42". Brand adalah label saat kompilasi, dan satu-satunya tugasnya adalah mencegah ID, satuan, dan string yang sudah divalidasi tertukar.
Masalahnya: Alias Hanya Sebuah Nama
Type alias tidak membuat tipe baru. Alias memberikan nama kedua untuk tipe yang sudah ada, dan compiler memperlakukan kedua nama itu sebagai hal yang sama:
Ini adalah structural typing: TypeScript memeriksa apakah bentuknya cocok, dan string cocok dengan string. Untuk objek, bentuknya biasanya berbeda; untuk ID, email, mata uang, dan satuan, bentuknya tidak pernah berbeda. Brand memperbaiki kasus yang satu itu.
Cara Kerja Brand
string & { readonly __brand: "UserId" } adalah intersection: sebuah nilai harus berupa string dan juga punya property __brand bertipe "UserId". Tidak ada string sungguhan yang punya property itu, jadi tidak ada string biasa yang bisa diberikan kepadanya:
index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
Types of property '__brand' are incompatible.
Type '"OrderId"' is not assignable to type '"UserId"'.
Arah yang penting tetap berjalan: UserId adalah string, jadi kamu bisa mengirimnya ke apa pun yang menerima string, memanggil .startsWith() padanya, atau memasukkannya ke template. Brand hanya menghalangi jalan masuknya.
Fungsi Constructor yang Memvalidasi
Assertion as UserId adalah satu-satunya jalan masuk, dan assertion tidak memeriksa apa pun. Taruh di satu fungsi yang memvalidasi input-nya, maka setiap nilai ber-brand di program dipastikan sudah lolos pemeriksaan itu:
sendWelcome tidak pernah memeriksa input-nya lagi, karena tipe parameternya menyatakan pemeriksaan sudah dilakukan. Inilah ide "parse, don't validate": periksa di batas, lalu bawa buktinya di dalam tipe. Type guard juga bisa berfungsi sebagai constructor kalau kamu lebih suka boolean daripada exception: function isEmail(s: string): s is Email.
Helper Brand Generic
Menulis intersection secara manual untuk setiap tipe jadi berulang-ulang. Satu generic kecil cukup melakukannya sekali:
Memberi brand pada number sama caranya dengan memberi brand pada string. Perhatikan bahwa amount / 100 adalah number biasa: aritmetika pada number ber-brand menghasilkan nilai tanpa brand, seperti dibahas di bawah.
Brand dengan unique symbol
Nama property berupa string seperti __brand terlihat seperti property sungguhan: userId.__brand lolos pemeriksaan tipe sebagai "UserId" tetapi bernilai undefined saat runtime, dan dua library bisa saja memilih nama yang sama. Key berupa unique symbol menghindari keduanya:
declare const brand: unique symbol mendeklarasikan symbol yang hanya ada untuk pemeriksa tipe; kata kunci declare berarti tidak ada JavaScript yang dihasilkan untuknya. Karena symbol itu tidak di-export dari modulnya, kode di file lain bahkan tidak bisa menyebut nama property brand-nya, jadi di luar modul itu satu-satunya cara mendapatkan Meters adalah lewat fungsi yang kamu export atau assertion as Meters.
Brand Tidak Punya Biaya Runtime
Output hasil kompilasi tidak menyisakan jejak brand sama sekali. Berikut baris-baris yang dihasilkan untuk contoh Meters, di bawah header modul yang ditambahkan compiler: declare, kedua type alias, dan setiap as sudah hilang, sementara panggilan yang error-nya disembunyikan di baris terakhir tetap dijalankan.
function toMeters(feet) {
return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);
Nilai ber-brand adalah primitif biasa: typeof menghasilkan "string" atau "number", JSON.stringify menuliskannya seperti biasa, dan perbandingan berjalan seperti sebelumnya. Sisi lainnya, tidak ada yang diperiksa saat runtime kecuali fungsi constructor-mu memeriksanya. Data hasil parse dari JSON, database, atau URL datang sebagai string, dan baru menjadi UserId setelah kamu melewatkannya ke fungsi tersebut.
Aritmetika dan Method Menghilangkan Brand
Operasi pada nilai ber-brand mengembalikan tipe dasarnya, karena brand bukan bagian dari hasil + atau .slice():
type Cents = number & { readonly __brand: "Cents" };
const a = 500 as Cents;
const b = 250 as Cents;
const sum = a + b; // number, not Cents
const total: Cents = a + b; // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid
Biasanya itulah yang kamu inginkan: menjumlahkan dua nominal dalam sen menghasilkan sen, tetapi mengalikan sen dengan sen tidak, dan hanya kamu yang tahu operasi mana yang mempertahankan maknanya. Tulis helper kecil seperti addCents(a: Cents, b: Cents): Cents untuk operasi yang dibutuhkan kodemu.
Kapan Memakai Branded Type
Pakai brand ketika tertukarnya dua nilai dengan tipe primitif yang sama adalah risiko nyata dan compiler tidak bisa membantu dengan cara lain:
| Situasi | Contoh brand |
|---|---|
| ID dari tabel yang berbeda | UserId, OrderId, ProductId |
| String yang sudah divalidasi | Email, Url, NonEmptyString, Slug |
| Satuan dan mata uang | Meters, Feet, Cents, Usd, Eur |
| Teks yang sudah disanitasi atau di-escape | SafeHtml, SqlIdentifier |
| Angka dengan rentang | Percentage, PositiveInt |
Lewati brand untuk nilai yang tidak pernah tertukar, dan untuk object type yang bentuknya sudah berbeda. Library validasi bisa menghasilkan branded type dari skema: di Zod, z.string().brand<"UserId">() menghasilkan skema yang parse-nya mengembalikan UserId ber-brand, sehingga kamu tidak perlu menulis fungsi constructor secara manual.
Pertanyaan yang sering diajukan
Apa itu branded type di TypeScript?
Sebuah pola yang membuat dua tipe dengan representasi runtime yang sama menjadi tidak kompatibel. Kamu meng-intersect tipe dasarnya dengan tag yang tidak dimiliki nilai biasa mana pun: type UserId = string & { readonly __brand: "UserId" }. String biasa, atau OrderId dengan tag yang berbeda, lalu ditolak di tempat yang mengharapkan UserId.
Apakah TypeScript punya nominal type?
Tidak. Sistem tipe TypeScript bersifat struktural: dua tipe dengan bentuk yang sama bisa saling menggantikan, apa pun namanya. Deklarasi class dengan member private atau #private berperilaku nominal, dan branded type adalah cara umum untuk mendapatkan efek yang sama pada primitif seperti string dan number.
Apakah branded type punya biaya saat runtime?
Tidak. Brand hanya ada di tipe. Saat runtime, nilainya tetap string atau number biasa, tanpa property tambahan, dan JavaScript hasil kompilasinya sama seperti tanpa brand. Satu-satunya kode runtime adalah validasi yang kamu pilih untuk ditaruh di fungsi yang membuat nilai ber-brand.
Bagaimana cara membuat nilai dengan branded type?
Dengan type assertion, sebaiknya di satu fungsi kecil yang memeriksa input-nya lebih dulu: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. Menaruh as hanya di satu tempat itu berarti setiap Email di program sudah lolos pemeriksaan.
Apa bedanya type alias dan branded type?
type UserId = string hanya nama baru: string apa pun diterima di mana pun UserId diharapkan. type UserId = string & { readonly __brand: "UserId" } adalah tipe baru yang tidak kompatibel: string biasa harus melewati fungsi constructor atau assertion lebih dulu.