TypeScript'te infer: Conditional Type İçinde Tip Yakalamak
infer ile kalıba uyan type'tan parça yakalamayı, ReturnType, Parameters ve Awaited'ı kendimiz yazmayı, tuple ve string kalıplarını, infer constraint'lerini ve overload tuzağını görüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Önce Tahmin Et
Bir array type'ının eleman tipini çıkaran küçük bir type yazdık:
type ElementOf<T> = T extends (infer Item)[] ? Item : never;
type A = ElementOf<string[]>;
type B = ElementOf<[number, boolean]>;
type C = ElementOf<readonly string[]>;
A, B ve C hangi type'lar? A için cevabın büyük ihtimalle hazır. B bir tuple; kalıba uyar mı, uyarsa ne yakalanır? C ise A'nın readonly hâli, sence aynı sonucu verir mi? Cevaplar, infer'ın nasıl çalıştığını gördükten sonra.
Kalıp Eşleme: infer Ne Yapar?
Bir önceki yazıda conditional type'larla bir type'ın bir kalıba uyup uymadığını sorduk. infer, TypeScript 2.8'de conditional type'larla birlikte geldi ve bu soruya bir ek yapar: "Kalıba uyuyorsa, şu boşluğa denk gelen type'ı yakala ve bana bir isimle ver."
JavaScript serilerinde gördüğümüz destructuring'i hatırla. const [first] = items; yazdığında, sağdaki değerin şekline bakıp bir parçasını bir değişkene bağlarsın. infer aynı işi type'lar için yapar. T extends (infer Item)[] şöyle okunur: "T bir array ise, elemanlarının type'ına Item de." Yakalanan Item yalnızca koşulun doğru dalında kullanılabilir; kalıp uymazsa yanlış dal çalışır ve orada Item yoktur.
Tahmin sorusunun ilk ikisi buradan çözülür. A için string[] kalıba uyar ve Item = string olur. B'de tuple da bir array'dir; [number, boolean], (number | boolean)[]'e atanabildiği için kalıba uyar ve sonuç number | boolean olur. C'nin cevabı tuzaklar bölümünde.
infer yalnızca conditional type'ın extends kısmında yazılabilir. Başka bir yerde kullanırsan derleyici açıkça söyler:
// Hata: 'infer' declarations are only permitted in the 'extends' clause of a conditional type.
type Broken<T> = infer U;
Bu hatanın kodu TS1338. Yani infer tek başına bir şey yapmaz; her zaman bir kalıbın içinde, bir boşluğu işaretler.
Kendi ReturnType'ını Yazmak
Orta seride ReturnType ve Parameters'ı hazır olarak kullandık. Şimdi ikisini de kendimiz yazalım:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number) {
return { name, age, active: true };
}
type NewUser = MyReturnType<typeof createUser>; // { name: string; age: number; active: boolean; }
type CreateUserArgs = MyParameters<typeof createUser>; // [name: string, age: number]
Kalıp "herhangi bir parametre listesi alan ve bir şey döndüren fonksiyon". MyReturnType'ta boşluğu dönüş tipine, MyParameters'ta parametre listesine koyduk. Parametreler tuple olarak yakalanır, hatta parametre isimleri named tuple etiketi olarak korunur. typeof createUser ise Orta seride gördüğümüz tip seviyesindeki typeof: değerden type'a geçmenin yolu.
Kalıpta any[] kullanmamız bilinçli. unknown[] yazsaydık, contravariance yüzünden (bunu serinin ilerisinde ayrıntılı konuşacağız) belirli parametre tipleri olan fonksiyonlar kalıba uymazdı. lib'deki gerçek tanımlar da bizimkine çok benzer; tek fark, T'ye bir constraint koymaları:
// lib.es5.d.ts içinden; zaten global oldukları için kendi dosyana kopyalama
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
Bu constraint sayesinde ReturnType<string> yazdığında derleyici never döndürmek yerine hatayı yerinde verir: "Type 'string' does not satisfy the constraint '(...args: any) => any'." Kendi tiplerinde de, yanlış kullanımı sessizce never'a çevirmek yerine constraint ile engellemek genellikle daha iyidir.
Kendi Awaited'ını Yazmak
Async bir fonksiyonun dönüş tipi bir Promise'tir. Çoğu zaman bize lazım olan ise promise'in içindeki değerin type'ıdır:
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
type N = MyAwaited<Promise<Promise<number>>>; // number
type S = MyAwaited<string>; // string
async function loadUser() {
return { id: 1, name: "Ada" };
}
type User = MyAwaited<ReturnType<typeof loadUser>>; // { id: number; name: string; }
function greet(user: User) {
console.log(`Hello, ${user.name}`);
}
loadUser().then(greet); // Hello, Ada
Burada yeni bir şey var: MyAwaited doğru dalında kendini çağırıyor. İç içe promise'leri, await'in yaptığı gibi katman katman açıyoruz. Promise değilse type'ın kendisini döndürüyoruz, çünkü await 5 de JavaScript'te 5 verir. Kendini çağıran type'lara serinin ilerisinde ayrı bir yazı ayıracağız.
TypeScript 4.5 ile gelen gerçek Awaited biraz daha titizdir: Promise yerine then method'u olan her object'i (thenable) açar, çünkü JavaScript'te await de böyle çalışır. Fikir ise aynı: kalıba uyan katmanı infer ile yakala, gerekiyorsa tekrar et.
Tuple'larda infer
Tuple kalıplarında rest elemanlarla birlikte infer'ı istediğin konuma koyabilirsin:
type First<T> = T extends [infer Head, ...unknown[]] ? Head : never;
type Last<T> = T extends [...unknown[], infer Tail] ? Tail : never;
type Rest<T> = T extends [unknown, ...infer R] ? R : never;
type F = First<[string, number, boolean]>; // string
type L = Last<[string, number, boolean]>; // boolean
type R = Rest<[string, number, boolean]>; // [number, boolean]
type E1 = First<[]>; // never
type E2 = First<string[]>; // never
First<[]> mantıklı: boş tuple'ın ilk elemanı yok. First<string[]> ise daha ilginç. string[] sıfır elemanlı da olabilir; "en az bir elemanı var" kalıbına garanti uymadığı için yanlış dal seçilir. Tuple kalıpları uzunluğu bilinen type'larla en iyi sonucu verir.
String'lerde infer
infer, template literal type'ların içinde de çalışır. Template literal type'ları iki yazı sonra ayrıntılı göreceğiz; şimdilik JavaScript'teki template literal'ın tip seviyesindeki karşılığı olduğunu bilmen yeterli:
type Method<T> = T extends `${infer M} ${string}` ? M : never;
type M1 = Method<"GET /users">; // "GET"
type SplitDot<T> = T extends `${infer Head}.${infer Tail}` ? [Head, Tail] : [T];
type P1 = SplitDot<"user.address.city">; // ["user", "address.city"]
Method, boşluktan önceki kısmı yakalıyor. SplitDot'taki sonuca dikkat et: Head ilk noktaya kadar olan en kısa parçayı alır, geri kalan her şey Tail'e gider. Soldan başlayıp ilk eşleşmede durur. Bütün parçaları ayırmak için Tail üzerinde aynı type'ı tekrar çağırman gerekir; bu da yine kendini çağıran type'lar konusuna çıkıyor.
infer'a Kısıt Eklemek
TypeScript 4.7 ile infer'a doğrudan bir constraint yazabiliyoruz. Yakalanan type constraint'e uymazsa kalıp tutmamış sayılır:
type FirstString<T> = T extends [infer F extends string, ...unknown[]] ? F : never;
type FS1 = FirstString<["Ada", 1]>; // "Ada"
type FS2 = FirstString<[1, "Ada"]>; // never
type ToNumber<T> = T extends `${infer N extends number}` ? N : never;
type T1 = ToNumber<"42">; // 42
type T2 = ToNumber<"1.0">; // number
type T3 = ToNumber<"abc">; // never
ToNumber TypeScript 4.8 ile gelen bir inceliği kullanıyor: template literal içinde number ile kısıtlanmış bir infer, string'i literal bir sayı type'ına çevirmeye çalışır. "42" için sonuç 42. "1.0" için ise sadece number, çünkü derleyici yalnızca sayıya çevrilip tekrar string'e döndüğünde aynı kalan değerleri literal'a çevirir; String(Number("1.0")) ise "1" verir. "abc" hiç sayı olmadığı için kalıp tutmaz.
Aynı İsim İki Yerde: Union mı, Intersection mı?
Aynı infer değişkenini kalıbın birden fazla yerinde kullanabilirsin. O zaman derleyici birden fazla aday bulur ve bunları birleştirmesi gerekir. Nasıl birleştireceği, değişkenin hangi konumda durduğuna bağlıdır:
type PropUnion<T> = T extends { a: infer U; b: infer U } ? U : never;
type U1 = PropUnion<{ a: string; b: number }>; // string | number
type ParamBoth<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never;
type U2 = ParamBoth<{ a: (x: { id: number }) => void; b: (x: { name: string }) => void }>; // { id: number; } & { name: string; }
Property konumunda iki aday union ile birleşir: değer ya string ya number olabilir. Fonksiyon parametresi konumunda ise intersection ile birleşir: iki fonksiyona da verilebilecek tek değer, ikisinin beklentisini birden karşılayan değerdir. Aynı denemeyi string ve number parametreleriyle yaparsan string & number, yani never çıkar. Bu konum farkının adı variance; ayrıntısını serinin ilerisinde ayrı bir yazıda konuşacağız.
Sık Düşülen Tuzaklar
Tahmin sorusundaki C ile başlayalım: ElementOf<readonly string[]> sonucu never. Readonly bir array, normal array'e atanamaz (push gibi method'ları yoktur), dolayısıyla (infer Item)[] kalıbına uymaz. Kalıbı readonly (infer Item)[] diye yazarsan iki türü de yakalarsın, çünkü normal array readonly array'e atanabilir.
İkinci tuzak overload'lar. Overload'lı bir fonksiyona infer uyguladığında derleyici yalnızca son imzaya bakar:
function parse(input: string): number;
function parse(input: number): string;
function parse(input: string | number) {
return typeof input === "string" ? Number(input) : String(input);
}
type Parsed = ReturnType<typeof parse>; // string
console.log(parse("42") + 1); // 43
parse("42") aslında number döndüren imzayla eşleşiyor, ama ReturnType son imzadaki string'i görüyor. Overload'lı fonksiyonlarda ReturnType ve Parameters'a güvenme.
Üçüncüsü, yanlış dalda ne döndürdüğün. never döndürmek "bu kalıba uymayan şey olmamalı" demektir ve hatayı sessizce saklayabilir. Kalıba uymayan girdiler gerçekten anlamsızsa, lib'in yaptığı gibi generic parametreye bir constraint koyup hatayı kullanıldığı yerde göstermek daha iyidir.
Küçük Bir Deneme
★☆☆ Tahmin et, sonra doğrula: type Unwrap<T> = T extends Set<infer V> ? V : T; için Unwrap<Set<"a" | "b">> ve Unwrap<string> hangi type'lar?
★★☆ Bir fonksiyonun yalnızca ilk parametresinin type'ını veren FirstArg<T> yaz. FirstArg<(id: number, name: string) => void> sonucu number olmalı.
★★★ Baştaki boşlukları silen bir TrimLeft<S extends string> yaz: kalıp ` ${infer Rest}`, doğru dalda kendini Rest ile çağır. TrimLeft<" hello"> sonucu "hello" olmalı.
Bu yazıda bir type'ı alıp içinden tek bir parça çıkardık. Bir sonraki yazıda bir object type'ın bütün property'leri üzerinde tek tek gezip yepyeni bir object type üreteceğiz: mapped type'lar, readonly ve ? modifier'ları ve as ile key'leri yeniden adlandırmak.