Intersection ve Structural Typing: TypeScript Tiplere Nasıl Bakar?
TypeScript'in tiplere isimle değil şekille baktığı structural typing'i, atanabilirlik kuralını, excess property kontrolüyle ilişkisini ve & ile type'ları birleştiren intersection'ı örneklerle görüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Önce Tahmin Et
Önceki yazıda fonksiyonların parametre ve dönüş tiplerini konuştuk. Şimdi bir adım geri çekilip daha temel bir soru soralım: bir fonksiyona object geçirdiğinde TypeScript tam olarak neyi kontrol ediyor? Şu koda bak:
type Point = { x: number; y: number };
function printPoint(p: Point) {
console.log(`${p.x}, ${p.y}`);
}
const place = { x: 10, y: 20, label: "Home" };
printPoint(place);
printPoint({ x: 10, y: 20, label: "Home" });
İki çağrı da aynı değeri gönderiyor gibi görünüyor. Bu kod derlenir mi? Hata verirse hangi satırda? Tahminini bir kenara yaz; cevabı, TypeScript'in tiplere nasıl baktığını gördükten sonra vereceğiz.
Structural Typing: İsme Değil Şekle Bakmak
Java ya da C# gibi dillerden geliyorsan, iki type'ın uyumlu olması için genelde aynı isimde olmaları ya da birinin diğerinden açıkça türemesi gerekir. Buna nominal typing denir. TypeScript farklı çalışır: bir değerin bir type'a uyup uymadığına, o değerin şekline, yani hangi property'lere ve hangi tiplere sahip olduğuna bakarak karar verir. Buna structural typing denir. JavaScript dünyasında "duck typing" diye bilinen fikrin derleme zamanındaki hâlidir: ördek gibi yürüyor ve ördek gibi ötüyorsa, ördektir.
class Cat {
nickname = "Tom";
speak() {
return "Meow";
}
}
class Robot {
nickname = "R2";
speak() {
return "Beep";
}
}
const pet: Cat = new Robot(); // derlenir: şekiller aynı
console.log(pet.speak()); // Beep
Robot ile Cat arasında hiçbir extends ilişkisi yok, ama ikisinin de bir nickname string'i ve string döndüren bir speak method'u var. TypeScript için bu yeterli. Bu davranış JavaScript'in ruhuna uygun: JavaScript serilerinde de bir fonksiyon, kendisine gelen object'in hangi class'tan geldiğiyle değil, üzerinde çağırdığı method'un var olup olmadığıyla ilgilenirdi.
Atanabilirlik Sezgisi: Fazlası Olur, Eksiği Olmaz
TypeScript'in hata mesajlarında en sık gördüğün kelime "assignable" olacak. Bir değerin bir type'a atanabilmesi için kuralın özü şu: hedef type'ın istediği her property kaynakta bulunmalı ve tipleri uyumlu olmalı. Kaynakta fazladan property olması sorun değildir.
type Named = { name: string };
type Person = { name: string; age: number };
const person: Person = { name: "Ada", age: 36 };
const named: Named = person; // derlenir: Person, Named'in istediği her şeye sahip
// Hata: Property 'age' is missing in type 'Named' but required in type 'Person'.
const back: Person = named;
Bunu bir iş ilanı gibi düşün. İlan "İngilizce bilen biri" istiyorsa, İngilizce ve Almanca bilen bir aday da uygundur; fazla yetenek kimseyi elemez. Ama ilan iki dil istiyorsa, tek dil bilen aday elenir. Person, Named ilanına başvurabilir; tersi olmaz. Hata kodu TS2741'dir ve eksik property'nin adını açıkça söyler.
Kontrol iç içe de işler. Bir property'nin tipi de bir object ise TypeScript aynı soruyu o seviyede tekrar sorar. Optional property'ler ise kaynakta hiç bulunmasa da olur; object tipleri yazısında gördüğümüz ? işareti "bu ilanda bu madde zorunlu değil" demektir.
Excess Property Kontrolüyle İlişkisi
Şimdi açılıştaki sorunun cevabı: ilk çağrı derlenir, ikincisi derlenmez. tsc şöyle der:
error TS2353: Object literal may only specify known properties, and 'label' does not exist in type 'Point'.
"Fazlası olur" kuralını az önce koyduk; o hâlde ikinci çağrı neden hata veriyor? Çünkü object tipleri yazısında gördüğümüz excess property kontrolü yalnızca fresh object literal'lara uygulanır, yani doğrudan yerinde yazılıp bir type'a karşılanan object'lere. Mantığı şu: { x: 10, y: 20, label: "Home" } literal'ını doğrudan printPoint'e verdiğinde, label'a başka hiçbir yerden ulaşmanın yolu yok. O property fonksiyon içinde de kullanılamayacağına göre büyük ihtimalle bir yazım hatasıdır ya da yanlış type'ı kullanıyorsundur. TypeScript bu durumda structural kuralı bilerek sıkılaştırır.
place değişkeninde ise durum farklı. O object'in bir adı var, label'ı kodun başka bir yerinde kullanıyor olabilirsin. TypeScript burada normal atanabilirlik kuralına döner: place'te x ve y var, gerisi fazlalık. Yani excess property kontrolü structural typing'in bir istisnası değil; yazım hatalarını yakalamak için literal'lara eklenmiş ek bir emniyet kemeri.
Intersection: & ile Type'ları Birleştirmek
Structural typing'i anladıysan intersection çok doğal gelir. A & B, "hem A'nın hem B'nin istediği her şeye sahip olan değerler" demektir. Ortak alanları ayrı type'lara çıkarıp gerektiğinde birleştirmek için idealdir:
type Timestamps = {
createdAt: Date;
updatedAt: Date;
};
type Product = {
id: string;
title: string;
price: number;
};
type StoredProduct = Product & Timestamps;
function save(product: Product): StoredProduct {
const now = new Date();
return { ...product, createdAt: now, updatedAt: now };
}
const saved = save({ id: "p1", title: "Mechanical Keyboard", price: 120 });
console.log(saved.title, saved.createdAt instanceof Date); // Mechanical Keyboard true
StoredProduct beş property'nin hepsini ister. Dönüş değerinden updatedAt'i silersen TypeScript eksik property'yi hemen raporlar. Type alias ve interface yazısında extends ile intersection'ı karşılaştırmıştık; burada önemli olan, intersection'ın herhangi iki type'ı, hatta isimsiz object tiplerini bile anında birleştirebilmesidir.
Union ve Intersection: İsimler Neden Ters Gibi Geliyor?
İlk karşılaşmada çoğu kişinin kafası burada karışır. Intersection "kesişim" demek, ama property'ler artıyor. Union "birleşim" demek, ama kullanabildiğin property'ler azalıyor. Anahtar, type'ları property listesi olarak değil, değer kümeleri olarak düşünmektir.
Product type'ı, Product'ın istediklerine sahip bütün değerlerin kümesidir. Product & Timestamps ise iki kümenin kesişimidir: hem ürün hem zaman damgası şartını sağlayan değerler. Şart arttıkça küme daralır, ama elindeki her değer hakkında daha çok şey bilirsin. Product | Timestamps ise iki kümenin birleşimidir: küme büyür, ama elindeki değerin hangisi olduğunu bilmediğin için yalnızca ortak olanı güvenle kullanabilirsin. Union'ları daraltma yazısında neden typeof, in gibi kontrollerle uğraştığımızın sebebi de buydu.
Çakışan Property'ler ve never
İki type aynı property'yi farklı tiplerle tanımlıyorsa ne olur?
type WithStringId = { id: string };
type WithNumberId = { id: number };
type Broken = WithStringId & WithNumberId;
// Hata: Type 'string' is not assignable to type 'never'.
const item: Broken = { id: "1" };
Kesişim mantığı burada da aynen işler: id hem string hem number olmalı. Böyle bir değer olamayacağı için id'nin tipi string & number, yani never olur. TypeScript tanımı reddetmez, sorun ancak o type'a bir değer atamaya çalıştığında ortaya çıkar. Bu yüzden intersection'ı daha çok, birbirine dokunmayan parçaları birleştirmek için kullan.
Sık Düşülen Tuzaklar
Birinci tuzak, "fazlası olur" kuralının çalışma zamanındaki sonucu. Tipler silinir; fazladan property'ler ise silinmez:
type PublicUser = { name: string };
function toJson(user: PublicUser) {
return JSON.stringify(user);
}
const account = { name: "Ada", password: "secret123" };
console.log(toJson(account)); // {"name":"Ada","password":"secret123"}
toJson'ın parametresi yalnızca name görüyor, ama gerçek object'te password da var ve JSON'a olduğu gibi giriyor. Parametre tipi bir filtre değildir. Bir API'ye ya da log'a giden veriyi daraltmak istiyorsan, yeni bir object'i elle kurman gerekir: { name: user.name }.
İkinci tuzak {} type'ı. Boş bir object gibi görünür, ama structural kurala göre "hiçbir property istemeyen type" demektir. null ve undefined dışında neredeyse her değer bu şartı sağlar; const value: {} = 42; hatasız derlenir. "Herhangi bir object" demek istiyorsan object ya da daha önce işaret ettiğimiz Record daha doğru araçlardır.
Üçüncü tuzak, excess property kontrolüne fazla güvenmek. Literal'da yaptığın yazım hatasını yakalar, ama aynı object önce bir değişkene konduysa yakalamaz. Optional property'lerle birleşince bu sinsi bir hataya dönüşür:
type UserSettings = { theme: string; fontSize?: number };
const settings = { theme: "dark", fontsize: 16 };
const applied: UserSettings = settings; // derlenir: theme var, fontSize optional
console.log(applied.fontSize); // undefined
fontsize küçük harfle yazıldığı için TypeScript onu fazlalık sayar; zorunlu tek property olan theme mevcut, fontSize zaten optional. Sonuç: derleyici sessiz, ayar ise hiç uygulanmıyor. Önemli yapılandırma object'lerini yerinde, literal olarak ve type'ıyla birlikte yazmak bu yüzden iyi bir alışkanlıktır.
Küçük Bir Deneme
★☆☆ Derlenir mi, tahmin et: const p3d = { x: 1, y: 2, z: 3 }; ardından printPoint(p3d);. Peki aynı object'i doğrudan printPoint({ x: 1, y: 2, z: 3 }) diye yazarsan? Her iki sonucu da bu yazıdaki iki kuralla açıkla.
★★☆ type Discounted = Product & { price: string }; tanımla. Sence Discounted'daki price property'sinin type'ı ne olur? Tahminini editörde üzerine gelerek doğrula.
★★☆ WithId (id: string) ve Draft (title: string, body: string) adında iki type yaz. Intersection ile Post type'ını kur ve WithId'yi eksik bırakan bir literal ile hatayı tetikle.
★★★ toJson örneğini, password gibi fazladan alanların asla JSON'a girmeyeceği şekilde yeniden yaz. İpucu: parametreden gelen object'i değil, kendi kurduğun yeni bir object'i serileştir.
Bu yazıda tiplerin kararını hep TypeScript'e bıraktık: o şekle baktı, biz sonucu kabul ettik. Bazen ise bir değer hakkında derleyiciden fazlasını bildiğimizi düşünür ve ona bunu söyleriz. Bir sonraki yazıda as ile type assertion'ı, ! işaretini, satisfies'ı ve as const'u konuşacağız; hangisinin seni koruduğunu, hangisinin sadece susturduğunu ayırt etmeyi öğreneceğiz.