TypeScript'te Conditional Types: Tip Seviyesinde Karar Vermek
T extends U ? X : Y sözdizimini, extends'in atanabilirlik anlamını, union'larda distributive davranışı, [T] ile dağılmayı kapatmayı ve never ile union filtrelemeyi tsc çıktılarıyla görüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Temel ve Orta serileri okuduğunu, generic'leri ve utility type'ları rahatça kullandığını varsayıyoruz. İleri seride type'ları elle yazmak yerine başka type'lardan üretmeyi öğreneceğiz; ilk durak, tip seviyesinde karar vermeyi sağlayan conditional type'lar.
Önce Tahmin Et
Aşağıda tek satırlık bir generic type var. Üç farklı type ile kullanıyoruz:
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<"hello">;
type B = IsString<string | number>;
type C = IsString<never>;
A, B ve C hangi type'lar? A kolay görünüyor. B için iki aday var: "no", çünkü string | number bir string değil; ya da başka bir şey. C ise çoğu kişinin ilk denemede yanıldığı yer. Tahminlerini bir kenara yaz; cevaplar, conditional type'ların union'larla nasıl çalıştığını gördükten sonra gelecek.
Tip Seviyesinde Bir Üçlü Operatör
JavaScript serilerinde koşul ? a : b şeklindeki üçlü operatörü defalarca kullandık. Conditional type, TypeScript 2.8 ile gelen ve aynı fikri type'lara taşıyan yapıdır:
// JavaScript'te değerler üzerinde: koşul ? a : b
const score = 42;
const label = score > 10 ? "big" : "small";
// TypeScript'te type'lar üzerinde: T extends U ? X : Y
type Label<T> = T extends string ? "text" : "other";
type L1 = Label<"Ada">; // "text"
type L2 = Label<42>; // "other"
Okuması şöyle: "T, string'e atanabiliyorsa sonuç "text", atanamıyorsa "other"." Koşul her zaman extends ile kurulur; == ya da && gibi operatörler yoktur. İki dal da birer type'tır ve istediğin kadar iç içe yazabilirsin.
Gerçek hayatta conditional type'lar genellikle bir generic'in içinde, gelen type'a göre farklı bir sonuç üretmek için kullanılır. Orta seride gördüğümüz indexed access ile birlikte küçük ama işe yarar bir örnek:
type ElementType<T> = T extends readonly unknown[] ? T[number] : T;
type E1 = ElementType<string[]>; // string
type E2 = ElementType<readonly [1, 2]>; // 1 | 2
type E3 = ElementType<boolean>; // boolean
Array ya da tuple gelirse elemanların type'ını, başka bir şey gelirse type'ın kendisini döndürüyoruz. readonly unknown[] kullanmamızın sebebi, hem normal array'lerin hem de readonly array'lerin bu kalıba uymasını istememiz.
Sonucu Görmek: Tip Seviyesinde console.log
Değerlerle çalışırken sonucu console.log ile görürüz. Tip seviyesinde böyle bir şey yok; type'lar çalışma zamanına hiç ulaşmaz. Editörde ya da TypeScript Playground'da type adının üzerine gelmek (hover) en hızlı yol. Terminalde çalışıyorsan ya da sonucu kesin görmek istiyorsan basit bir hile var: o type'a, kesinlikle atanamayacak bir değer ata ve derleyicinin itirazını oku.
type ElementType<T> = T extends readonly unknown[] ? T[number] : T;
// Hata: Type 'null' is not assignable to type '1 | 2'.
const reveal: ElementType<readonly [1, 2]> = null;
npx tsc --noEmit çıktısı tam olarak şu:
error TS2322: Type 'null' is not assignable to type '1 | 2'.
strict modda null neredeyse hiçbir type'a atanamadığı için derleyici hesapladığı type'ı mesajda açıkça yazar. Bir incelik var: önce type E2 = … diye bir alias tanımlayıp const reveal: E2 = null yazarsan mesaj sadece 'E2' der, açılımı göstermez. O yüzden ifadeyi doğrudan annotation'a yaz. Bu yazıdaki sonuç yorumlarının hepsini bu yolla TypeScript 7.0.2'de denetledik. Union üyelerinin sırası Playground'da farklı görünebilir; TypeScript 7 union'ları sabit bir sırayla yazdırıyor, ama union'da sıranın bir anlamı yok.
extends Burada Ne Demek?
Conditional type'taki extends, class'lardaki inheritance'ı değil, atanabilirliği sorar. Temel seride structural typing'i konuşurken kurduğumuz soru aynen geçerli: "Soldaki type'ın her değeri, sağdaki type'a atanabilir mi?"
type IsString<T> = T extends string ? "yes" : "no";
type R1 = IsString<"hello">; // "yes": literal type string'e atanabilir
type R2 = IsString<string>; // "yes"
type R3 = IsString<unknown>; // "no"
type R4 = IsString<any>; // "no" | "yes"
type HasId<T> = T extends { id: number } ? true : false;
type R5 = HasId<{ id: 1; name: "Ada" }>; // true: fazladan property sorun değil
R5 structural typing'in doğrudan sonucu: { id: 1; name: "Ada" } type'ı, { id: number } beklenen her yere konabilir. R4 ise özel bir durum: any hem atanabilir hem atanamaz gibi davrandığı için derleyici iki dalı birden alır ve union'larını döndürür. any'nin tip seviyesinde de sürpriz çıkardığını aklının bir köşesinde tut.
Distributive Conditional Types
Şimdi tahmin sorusundaki B'ye gelelim. Şu type'a bak:
type ToArray<T> = T extends unknown ? T[] : never;
type A1 = ToArray<string | number>; // string[] | number[]
type A2 = ToArray<boolean>; // false[] | true[]
Beklenti (string | number)[] olabilirdi, ama sonuç string[] | number[]. Sebebi şu kural: koşulun sol tarafında çıplak (naked) bir type parametresi varsa ve bu parametreye bir union gelirse, conditional type union'ın her üyesine ayrı ayrı uygulanır, sonuçlar yeniden union'la birleştirilir. Yani derleyici ToArray<string | number> için aslında ToArray<string> | ToArray<number> hesaplar. Buna distributive (dağılan) davranış denir.
A2 ilk bakışta tuhaf görünür, ama boolean aslında true | false union'ıdır; o yüzden o da dağılır. Tahmin sorusundaki B de böyle çözülür: IsString<string> sonucu "yes", IsString<number> sonucu "no", birleşince "no" | "yes".
Dağılma yalnızca iki koşul birlikte sağlanınca olur: koşulun solunda tek başına bir type parametresi duracak ve o parametre bir generic kullanımda union ile doldurulacak. Parametreyi bir yapının içine koyarsan ya da union'ı doğrudan yazarsan dağılma olmaz:
type Wrap<T> = T[] extends string[] ? "strings" : "other";
type W1 = Wrap<string | number>; // "other"
type Direct = string | number extends string ? "yes" : "no"; // "no"
Dağılmayı Kapatmak: [T] Hilesi
Bazen union'ı bütün olarak değerlendirmek istersin. Bunun için iki tarafı tek elemanlı tuple'a sararsın. [T] artık çıplak bir parametre değildir, dolayısıyla dağılma olmaz:
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type A3 = ToArrayAll<string | number>; // (string | number)[]
type IsNever<T> = [T] extends [never] ? true : false;
type N1 = IsNever<never>; // true
type N2 = IsNever<string>; // false
type IsNeverWrong<T> = T extends never ? true : false;
type N3 = IsNeverWrong<never>; // never
N3 tahmin sorusundaki C'nin cevabını da veriyor. Distributive bir conditional type, union'ın her üyesi için bir kez çalışır. never ise hiç üyesi olmayan union'dır; dağıtılacak üye olmadığı için sonuç da boş union, yani never olur. Bu yüzden IsString<never> ne "yes" ne "no", doğrudan never. Bir type'ın never olup olmadığını sorgulamak istiyorsan [T] extends [never] kalıbını ezberle.
never ile Filtreleme
Dağılma ilk bakışta bir tuzak gibi görünse de en çok işe yarayan özelliklerden biridir. never, union içinde iz bırakmadan kaybolur (X | never sadece X'tir). Bu ikisini birleştirince union'ları süzen bir filtre elde edersin:
type OnlyStrings<T> = T extends string ? T : never;
type S1 = OnlyStrings<"click" | 42 | "scroll" | true>; // "click" | "scroll"
Her üye ayrı ayrı denetlenir: string olanlar kendisi olarak kalır, diğerleri never olup kaybolur. Orta seride kullandığımız Exclude ve Extract tam olarak bu fikir. TypeScript'in kendi lib dosyasındaki tanımları şöyle:
// lib.es5.d.ts içinden; zaten global oldukları için kendi dosyana kopyalama
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
Atanabilirlik denetimi object type'larla da çalıştığı için discriminated union'ları süzmek için birebirdir:
type Action =
| { type: "add"; item: string }
| { type: "remove"; id: number }
| { type: "clear" };
type PayloadAction = Exclude<Action, { type: "clear" }>;
type PayloadType = PayloadAction["type"]; // "add" | "remove"
function logPayload(action: PayloadAction) {
if (action.type === "add") {
console.log(`add ${action.item}`);
} else {
console.log(`remove ${action.id}`);
}
}
logPayload({ type: "add", item: "Keyboard" }); // add Keyboard
{ type: "clear" } üyesi { type: "clear" }'a atanabildiği için never olur ve union'dan düşer. Kalan iki üyenin ikisinde de taşıyacak bir veri var. NonNullable da eskiden aynı kalıpla yazılıyordu; bugünkü lib'de T & {} olarak tanımlı, ama sonuç aynı.
Sık Düşülen Tuzaklar
İlk tuzak, generic bir fonksiyonun dönüş tipini conditional type ile yazıp gövdede de onu sağlamayı beklemek:
function describe<T extends string | number>(value: T): T extends string ? "text" : "number" {
// Hata: Type '"text"' is not assignable to type 'T extends string ? "text" : "number"'.
return typeof value === "string" ? "text" : "number";
}
Derleyici aynı satırda "number" için de aynı hatayı (TS2322) verir. Fonksiyonun içinde T henüz belli değildir; conditional type çözülemeden ertelenmiş (deferred) hâlde kalır ve typeof ile yaptığın narrowing T'nin kendisini değiştirmez. Böyle bir API için overload'lar daha dürüst bir araçtır:
function describe(value: string): "text";
function describe(value: number): "number";
function describe(value: string | number): "text" | "number" {
return typeof value === "string" ? "text" : "number";
}
const kind = describe("Ada"); // "text"
console.log(kind); // text
İkinci tuzak boolean'ın bir union olduğunu unutmak. ToArray<boolean> sonucunun boolean[] değil false[] | true[] olması gerçek kodda "neden bu array'e true ekleyemiyorum?" sorusuna dönüşebilir. Union'ı bütün olarak istiyorsan [T] ile sar.
Üçüncüsü never'ın sessizce yayılması. Distributive bir type'a never verirsen sonuç da never olur ve bu, birkaç katman sonra anlamsız hatalar olarak karşına çıkabilir. Dördüncüsü ise any: iki dalın union'ını döndürdüğü için, any sızan bir yerde conditional type'ın beklediğin dalı seçmesini bekleme.
Küçük Bir Deneme
★☆☆ Tahmin et, sonra null hilesiyle doğrula: type Flip<T> = T extends true ? false : true; tanımında Flip<boolean> hangi type? Dağılmayı aklında tut.
★★☆ null ve undefined'ı union'dan atan bir RemoveNullish<T> yaz. RemoveNullish<string | null | undefined> sonucunu NonNullable ile karşılaştır.
★★★ type Keys<T> = T extends unknown ? keyof T : never; ile Keys<{ a: 1 } | { b: 2 }> ne verir? Peki doğrudan keyof ({ a: 1 } | { b: 2 }) yazarsan? İkisinin neden farklı olduğunu dağılma kuralıyla açıkla.
Şimdiye kadar conditional type'larla hep evet-hayır sorusu sorduk: "bu type şuna atanabilir mi?" Bir sonraki yazıda soruyu bir adım ileri taşıyacağız: infer ile kalıba uyan type'ın içinden bir parçayı yakalayıp kullanacak, ReturnType ve Awaited'ı kendimiz yazacağız.