Branded types untuk ID safety di TypeScript
Hindari bug 'kirim userId ke function yang expect orderId'. Pakai branded types — TypeScript akan complain kalau Anda salah pass.
Dipublikasikan 28 Mei 2026
Bug paling annoying di codebase besar: function expect userId: string, Anda pass orderId: string. Compiler happy (both string), runtime crash. Branded types fix this.
Kode
// types/branded.ts
declare const __brand: unique symbol;
export type Brand<T, B extends string> = T & { [__brand]: B };
export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
export type ProductId = Brand<string, 'ProductId'>;
export type Email = Brand<string, 'Email'>;
export type Rupiah = Brand<number, 'Rupiah'>;
// Smart constructor (validate + brand)
export function asUserId(s: string): UserId {
if (!s.match(/^user_[a-z0-9]{12}$/)) {
throw new Error(`Invalid UserId: ${s}`);
}
return s as UserId;
}
export function asEmail(s: string): Email {
if (!s.includes('@')) throw new Error(`Invalid email: ${s}`);
return s as Email;
}
export function asRupiah(n: number): Rupiah {
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid rupiah: ${n}`);
return Math.round(n) as Rupiah;
}
Pemakaian
import { UserId, OrderId, asUserId, asOrderId } from './types/branded';
function deleteUser(userId: UserId) {
return db.delete('users', { id: userId });
}
function getOrder(orderId: OrderId) {
return db.select('orders', { id: orderId });
}
// OK — proper typing
const userId = asUserId('user_abc123def456');
deleteUser(userId);
// COMPILE ERROR — TS complain
const orderId = asOrderId('ord_xyz789');
deleteUser(orderId);
// Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
// COMPILE ERROR — plain string juga
deleteUser('user_abc123def456');
// Argument of type 'string' is not assignable to parameter of type 'UserId'.
Kapan dipakai
- ID identifiers (UserId, OrderId, ProductId) — paling common gain
- Validated strings (Email, PhoneNumber, URL) — pasangan dengan validator
- Money types (Rupiah, USD) — prevent unit confusion
- Encoded strings (Base64, JWT, Slug) — semantic typing
Yang BUKAN match
Branded types tidak prevent runtime confusion (Anda bisa cast manually 'random' as UserId). Mereka tidak menggantikan validasi. Mereka complement validasi.
Performance
Zero runtime cost. Brand symbol hilang di compile time. Output JS identik dengan plain string.
Catatan
- Brand symbol pakai
declare constsupaya tidak muncul di runtime - Smart constructor (
asUserId) untuk validate sebelum brand - Untuk deserialization (parse JSON dari API): cast atau re-validate. Type guard:
function isUserId(s: unknown): s is UserId {
return typeof s === 'string' && s.match(/^user_[a-z0-9]{12}$/) !== null;
}
const data = await fetchUser();
if (isUserId(data.id)) {
// data.id sekarang UserId di scope ini
deleteUser(data.id);
}
Variasi: nominal vs structural
TypeScript by default structural — {name: string} dan {name: string, age: number} compatible kalau passing first to second. Branded types add nominal layer di atas structural.
Untuk database row types saya pakai branded:
type DbUser = Brand<{
id: UserId;
email: Email;
name: string;
createdAt: Date;
}, 'DbUser'>;
type ApiUser = Brand<{
id: UserId;
email: Email;
name: string;
createdAt: string; // ISO string, beda dengan DbUser
}, 'ApiUser'>;
Compiler complain kalau pass DbUser ke function expect ApiUser. Catches bugs at refactor time.
Library yang implement ini lengkap:
type-fest,ts-essentials. Saya prefer write manually untuk kontrol penuh.
# tags
Ditulis oleh Asti Larasati · 28 Mei 2026