Mert Özen Her Satırda Daha İleri
Frontend TypeScript İleri — #07

TypeScript'te Branded Tipler: UserId ile OrderId'yi Karıştırmamak

Mert Özen 3 Kas 2026 7 dk 1 görüntülenme
TypeScript'te Branded Tipler: UserId ile OrderId'yi Karıştırmamak

Structural typing'in aynı şekle sahip ama anlamı farklı değerleri ayıramadığı yerde brand'leri, doğrulama fonksiyonlarıyla brand üretmeyi ve unique symbol ile daha sağlam bir Brand yardımcısı yazmayı görüyoruz.

Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.

Önce Tahmin Et

İki ayrı kimlik için iki ayrı isim tanımladık. Bir fonksiyon sipariş kimliği bekliyor, biz ona kullanıcı kimliği veriyoruz:

type UserId = string;
type OrderId = string;

function cancelOrder(id: OrderId) {
  console.log(`Cancelling order ${id}`);
}

const userId: UserId = "u_42";
cancelOrder(userId);

Bu kod derlenir mi? Hata verirse hangi satırda? İsimler farklı, niyetimiz de farklı; ama TypeScript bu niyeti görüyor mu? Cevabı bir sonraki bölümde, structural typing'in nerede durduğunu konuşurken vereceğiz.

Structural Typing'in Sınırı

Kod tertemiz derlenir ve konsola Cancelling order u_42 yazar. Hiçbir uyarı yok. Çünkü type UserId = string yeni bir type oluşturmaz, var olan string type'ına ikinci bir isim verir. Derleyicinin gözünde UserId, OrderId ve string aynı şeydir.

Daha önce structural typing'i ve bir önceki yazıda atanabilirliği konuştuk: TypeScript iki type'ı isimlerine göre değil, şekillerine göre karşılaştırır. Aynı şekle sahip iki interface de birbirinin yerine geçer:

interface Celsius {
  value: number;
}
interface Fahrenheit {
  value: number;
}

function showFahrenheit(temp: Fahrenheit) {
  console.log(`${temp.value} F`);
}

const today: Celsius = { value: 22 };
showFahrenheit(today);

Bu da derlenir ve 22 F yazar; oysa 22 derece Celsius yaklaşık 72 Fahrenheit eder. Çoğu zaman structural typing çok işimize yarar: bir fonksiyon "value property'si olan her şeyi" kabul edebilir, ayrı ayrı class hiyerarşileri kurmak zorunda kalmayız. Ama kimlikler, para birimleri, ölçü birimleri, doğrulanmış ve doğrulanmamış veriler gibi konularda şekil aynıdır, anlam farklıdır. Java ya da C# gibi dillerde type'lar isimleriyle ayrılır; buna nominal typing denir. TypeScript'te nominal typing yok, ama onu taklit etmenin yerleşik bir yolu var: brand.

Brand Eklemek

Fikir basit: string'e, sadece tip seviyesinde var olan ayırt edici bir property ekliyoruz. Bunu intersection ile yapıyoruz:

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function cancelOrder(id: OrderId) {
  console.log(`Cancelling order ${id}`);
}

const userId = "u_42" as UserId;

cancelOrder(userId); // Hata
cancelOrder("o_7"); // Hata

Artık iki satır da reddediliyor. İlki için tsc şunu söylüyor:

error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
  Type 'UserId' is not assignable to type '{ readonly __brand: "OrderId"; }'.
    Types of property '__brand' are incompatible.
      Type '"UserId"' is not assignable to type '"OrderId"'.

İkinci satırda da düz bir string'in { readonly __brand: "OrderId"; } şekline uymadığını görüyor. __brand property'lerinin literal type'ları farklı olduğu için şekiller artık farklı ve structural typing bu sefer bizim lehimize çalışıyor.

Burada bir şeyi fark et: __brand diye bir property çalışma zamanında hiç yok. String'lerin property eklenmiş bir hâli olmaz; biz derleyiciye küçük, zararsız bir yalan söylüyoruz. Bu yüzden brand'li bir değeri oluştururken as kullanmak zorundayız. Yukarıdaki örnekte bunu doğrudan yaptık; gerçek kodda ise bu as'ı tek bir yere kapatmak isteriz.

Brand'i Doğrulama Fonksiyonuyla Üretmek

Brand'in asıl değeri, "bu değer kontrol edildi" bilgisini type'a taşımasıdır. O yüzden as UserId yazmaya izin verdiğimiz tek yer, doğrulamayı yapan fonksiyon olmalı:

type UserId = string & { readonly __brand: "UserId" };

function toUserId(raw: string): UserId {
  if (!/^u_\d+$/.test(raw)) {
    throw new Error(`Invalid user id: ${raw}`);
  }
  return raw as UserId;
}

const id = toUserId("u_42");
console.log(id.toUpperCase()); // U_42
console.log(typeof id); // string
console.log(JSON.stringify({ id })); // {"id":"u_42"}

Bu desene "smart constructor" de deniyor. Kodun geri kalanı UserId bekleyen bir fonksiyonu çağırmak istiyorsa önce toUserId'den geçmek zorunda. Böylece doğrulamayı atlamak bir tip hatasına dönüşüyor. Çıktıya da bak: id hâlâ sıradan bir string. Bütün string method'larını kullanabiliyorsun, typeof "string" diyor ve JSON'a çevirdiğinde hiçbir ek alan çıkmıyor. Brand'in çalışma zamanında maliyeti sıfır.

Fırlatmak yerine daraltmak istersen Orta seride gördüğümüz type predicate'leri kullanabilirsin:

type Email = string & { readonly __brand: "Email" };

function isEmail(value: string): value is Email {
  return value.includes("@");
}

function sendWelcome(to: Email) {
  console.log(`Welcome mail sent to ${to}`);
}

const input = "ada@example.com";

if (isEmail(input)) {
  sendWelcome(input); // Welcome mail sent to ada@example.com
}

if bloğunun içinde input artık Email; bloğun dışında yine düz string. Assertion function (asserts value is Email) da aynı işi görür, tercih senin hata akışına bağlı.

unique symbol ile Daha Sağlam Brand

__brand adında bir string key kullanmanın iki küçük sorunu var. Birincisi, editör bu property'yi otomatik tamamlamada gösterir ve birisi gerçekten id.__brand yazmaya kalkabilir. İkincisi, iki farklı library aynı __brand adını kullanırsa brand'leri çakışabilir. Çözüm, key olarak bir unique symbol kullanmak ve işi küçük bir generic yardımcıya bağlamak:

declare const brand: unique symbol;

type Brand<T, Name extends string> = T & { readonly [brand]: Name };

type UserId = Brand<string, "UserId">;
type Cents = Brand<number, "Cents">;

function cents(amount: number): Cents {
  if (!Number.isInteger(amount)) {
    throw new Error("Cents must be an integer");
  }
  return amount as Cents;
}

function addCents(a: Cents, b: Cents): Cents {
  return (a + b) as Cents;
}

const total = addCents(cents(1999), cents(500));
console.log(total); // 2499

const raw = cents(100) + cents(50); // raw: number, brand kayboldu

declare const brand: unique symbol satırı çalışma zamanında hiçbir şey üretmez; derleyiciye "böyle bir sabit var ve type'ı yalnızca ona özel bir symbol" der. Bu symbol'e module dışından erişilemediği için kimse brand'i elle taklit edemez ve başka bir module'deki aynı isimli brand'le karışmaz. declare tamamen silindiği için bu dosya node file.ts ile de sorunsuz çalışır.

Son satır önemli bir davranışı gösteriyor: brand'li iki number'ı topladığında sonuç düz number olur. Aritmetik operatörler brand'i korumaz. Bu aslında doğru bir davranış; iki kuruş değerini toplamak anlamlı, ama çarpmak (kuruş kare?) anlamsız. Hangi işlemin brand'i koruyacağına sen karar verirsin ve bunu addCents gibi küçük fonksiyonlarla ifade edersin.

Class'larda Nominal Davranış Kendiliğinden Gelir

Bir istisnayı bilmekte fayda var: class'larda #private ya da private bir üye varsa, TypeScript o class'ı nominal gibi karşılaştırır.

class Meters {
  #kind = "meters";
  value: number;
  constructor(value: number) {
    this.value = value;
  }
}

class Feet {
  #kind = "feet";
  value: number;
  constructor(value: number) {
    this.value = value;
  }
}

function climb(height: Meters) {
  console.log(`Climbing ${height.value} m`);
}

climb(new Feet(30)); // Hata
climb({ value: 30 }); // Hata

İlk çağrı için tsc, Feet'teki #kind'ın Meters'takiyle aynı üye olmadığını söylüyor (TS2345); ikincisinde düz object'te #kind eksik (TS2741). Private üyeler yalnızca tanımlandıkları class'a ait olduğu için şekil aynı olsa bile atama yapılamıyor. Değerin zaten bir class instance'ı olması gereken yerlerde bu yeterli bir çözüm. Ama string ve number gibi primitive'leri sarmalamak için her seferinde class instance'ı üretmek hem bellek hem de JSON tarafında maliyetlidir; orada brand daha hafif kalır.

Sık Düşülen Tuzaklar

Birinci tuzak, brand'i opsiyonel yapmak. string & { readonly __brand?: "UserId" } yazarsan herhangi bir string bu type'a atanabilir, çünkü opsiyonel bir property'nin eksik olması sorun değildir. Brand ancak zorunlu olduğunda koruma sağlar.

İkinci tuzak, as'ı her yere serpiştirmek. const id = request.params.id as UserId gibi satırlar çoğaldıkça brand bir doğrulama garantisi olmaktan çıkar, sadece bir etikete dönüşür. Kural basit: as Brand yalnızca doğrulama fonksiyonunun içinde.

Üçüncü tuzak, sınırda doğrulamayı unutmak. JSON.parse ya da bir API cevabı sana brand'li değer veremez; çalışma zamanında brand diye bir şey yok. Dış dünyadan gelen veriyi önce doğrula, brand'i doğrulama sonrası ekle. Orta serideki unknown ve runtime doğrulama konusu burada da geçerli.

Dördüncüsü, aynı brand symbol'ünü kullanan iki brand'i intersection ile birleştirmeye çalışmak. Bunun neden beklediğin gibi çalışmadığını aşağıdaki denemelerden birinde kendin göreceksin.

Küçük Bir Deneme

★☆☆ Derlenir mi, tahmin et: Cents örneğinde const a = cents(10); const b: Cents = a * 2;. Hata verirse mesajın hangi type'tan söz ettiğine dikkat et.

★★☆ Email brand'i için assertEmail(value: string): asserts value is Email imzalı bir assertion function yaz. Geçersiz değerde Error fırlatsın; çağrıdan sonraki satırda değişkenin type'ının Email'e daraldığını editörde kontrol et.

★★★ Brand<number, "Positive"> & Brand<number, "Int"> type'ını oluştur ve üzerine gel. Ne görüyorsun ve neden? Sonra brand'in değerini string literal yerine { [K in Name]: true } gibi bir object type yaparak iki brand'in birlikte yaşayabildiği bir Brand yaz.

Brand'ler tamamen tip katmanında yaşıyor; derleyici işini bitirdiğinde arkalarında tek bir satır bırakmıyorlar. Bir sonraki yazıda bunun tam tersi bir özelliğe bakacağız: class'ların davranışını çalışma zamanında değiştiren ve bu yüzden silinip geçilemeyen decorator'lar.