TypeScript'te Tip Seviyesinde Programlama: type-challenges Kolay Seviye Çözümleri
type-challenges'ın kolay seviyesinden on soruyu (MyPick, MyReadonly, TupleToObject, First, Length, MyExclude, MyAwaited, If, Concat, Includes) Equal ve Expect ile yazılmış tip seviyesinde testlerle çözüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Önce Tahmin Et
Tek satırlık bir type ve üç kullanım:
type Length<T extends readonly unknown[]> = T["length"];
type A = Length<["a", "b", "c"]>;
type B = Length<string[]>;
type C = Length<"abc">;
TypeScript A ve B için hangi type'ı çıkarır? C satırı derlenir mi? "abc"'nin de bir length'i var, sonuçta. Cevap, bu yazının bulmacalarından birinde.
type-challenges ve Tip Seviyesinde Test
type-challenges, GitHub'da topluluğun hazırladığı bir tip bulmacaları koleksiyonu. Kolaydan çok zora doğru dizilmiş yüzlerce soru var; kolay seviyedeki 13 sorunun çoğu, TypeScript'in hazır utility type'larını kendin yeniden yazmanı istiyor. Bu yazıda onlardan on tanesini çözeceğiz. Hepsi bu serinin önceki yazılarında gördüğümüz mapped type, conditional type, infer ve recursive type'ların küçük birer uygulaması.
Bulmacalar çalışan bir program değil, derlenen ya da derlenmeyen type'lar üretiyor. Peki bir type'ın doğru olduğunu nasıl test ederiz? type-challenges bunun için iki küçük yardımcı kullanıyor:
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type MyExclude<T, U> = T extends U ? never : T;
type Wrong = Expect<Equal<MyExclude<"a" | "b" | "c", "a">, "b">>; // Hata
Expect yalnızca true kabul ediyor. Equal iki type tam olarak aynıysa true, değilse false üretiyor. Beklentin yanlışsa derleyici şöyle der:
error TS2344: Type 'false' does not satisfy the constraint 'true'.
Yani testlerimiz derleme hataları. tsc --noEmit sessiz kaldıysa bütün testler geçmiş demektir. Equal'ın tuhaf görünen tanımı, iki generic fonksiyon imzasını karşılaştırarak derleyicinin iç "aynı type mı" kontrolünü tetikliyor. Neden daha basit bir tanım kullanmadığımızı Sık Düşülen Tuzaklar bölümünde göreceksin. Bir de // @ts-expect-error kullanacağız: bir sonraki satırda hata bekliyoruz, hata çıkmazsa derleyici bunu raporlar. "Bu girdiyi kabul etmemeli" testleri böyle yazılır.
Bu yazıdaki diğer bütün blokların başında bu iki yardımcının durduğunu varsay; her bloğu bu şekilde tsc ile denetledik.
MyPick ve MyReadonly: Mapped Type Isınması
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyReadonly<T> = { readonly [P in keyof T]: T[P] };
interface Todo {
title: string;
description: string;
completed: boolean;
}
type PickCases = [
Expect<Equal<MyPick<Todo, "title">, { title: string }>>,
Expect<Equal<MyPick<Todo, "title" | "completed">, { title: string; completed: boolean }>>,
// @ts-expect-error
MyPick<Todo, "title" | "invalid">,
];
type ReadonlyCases = [Expect<Equal<MyReadonly<Todo>, Readonly<Todo>>>];
İkisi de mapped type yazısındaki kalıbın aynısı. MyPick'te kilit nokta constraint: K extends keyof T olmadan T[P] yazamazsın, çünkü TypeScript P'nin T'nin bir key'i olduğundan emin olamaz. Aynı constraint, "invalid" gibi var olmayan bir key'i de reddediyor; @ts-expect-error satırı bu yüzden sessiz. MyReadonly'de de readonly modifier'ını her property'ye ekliyoruz.
TupleToObject: Tuple'dan Object'e
type TupleToObject<T extends readonly PropertyKey[]> = { [V in T[number]]: V };
const sizes = ["small", "medium", "large"] as const;
type TupleCases = [
Expect<Equal<TupleToObject<typeof sizes>, { small: "small"; medium: "medium"; large: "large" }>>,
Expect<Equal<TupleToObject<readonly [1, 2]>, { 1: 1; 2: 2 }>>,
// @ts-expect-error
TupleToObject<[[1, 2], {}]>,
];
Burada iki tanıdık parça birleşiyor. T[number], indexed access ile tuple'ın bütün elemanlarını bir union'a çeviriyor: "small" | "medium" | "large". Mapped type da bu union'ın her üyesini hem key hem value yapıyor. Constraint'teki readonly önemli: as const readonly bir tuple üretiyor ve readonly bir tuple, mutable bir array constraint'ine uymaz. PropertyKey ise string | number | symbol'ün hazır adı; içinde array ya da object olan bir tuple'ı bu sayede reddediyoruz, çünkü onlar key olamaz.
First ve Length: Tuple'larla Oynamak
type First<T extends readonly unknown[]> = T extends readonly [infer F, ...unknown[]] ? F : never;
type Length<T extends readonly unknown[]> = T["length"];
type FirstCases = [
Expect<Equal<First<[3, 2, 1]>, 3>>,
Expect<Equal<First<[]>, never>>,
Expect<Equal<First<[undefined]>, undefined>>,
Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
// @ts-expect-error
First<"notArray">,
];
type LengthCases = [
Expect<Equal<Length<["a", "b", "c"]>, 3>>,
Expect<Equal<Length<[]>, 0>>,
Expect<Equal<Length<string[]>, number>>,
// @ts-expect-error
Length<5>,
];
First için akla ilk gelen T[0]. Çoğu durumda doğru sonuç verir, ama boş tuple'da never yerine undefined üretir ve First<[]> ile First<[undefined]> aynı görünür. infer ile "en az bir elemanı olan tuple" kalıbını tarif edersek boş tuple kalıba uymaz ve never'a düşer.
Length ise tek satır, ve baştaki sorunun cevabı burada. Tuple'larda length bir literal type'tır: A 3 olur. Sıradan bir array'in uzunluğu derleme zamanında bilinmez, B number olur. C ise derlenmez: "abc"'nin bir length property'si olsa da string bir array değil ve constraint'i geçemiyor. tsc'nin mesajı "Type 'string' does not satisfy the constraint 'readonly unknown[]'." (TS2344).
MyExclude ve If: Distributive Davranışı Kullanmak
type MyExclude<T, U> = T extends U ? never : T;
type If<C extends boolean, T, F> = C extends true ? T : F;
type ExcludeCases = [
Expect<Equal<MyExclude<"a" | "b" | "c", "a">, "b" | "c">>,
Expect<Equal<MyExclude<string | number | (() => void), Function>, string | number>>,
];
type IfCases = [
Expect<Equal<If<true, "a", "b">, "a">>,
Expect<Equal<If<false, "a", 2>, 2>>,
Expect<Equal<If<boolean, "a", 2>, "a" | 2>>,
// @ts-expect-error
If<null, "a", "b">,
];
MyExclude, conditional type yazısındaki distributive davranışın ders kitabı örneği. T çıplak bir tip parametresi olduğu için union'ın her üyesi ayrı ayrı denetleniyor: "a" never'a dönüşüyor, diğerleri kalıyor, sonuçlar yeniden birleşiyor. Union'dan never düşer ve geriye "b" | "c" kalır.
If'te aynı mekanizma bir sürpriz yaratıyor. boolean aslında true | false union'ı; dağıtılınca iki dal da çalışıyor ve sonuç "a" | 2 oluyor. type-challenges testlerinde beklenen cevap bilerek bu. Sadece kesin true için ilk dalı istiyorsan, conditional yazısında gördüğümüz gibi iki tarafı köşeli paranteze alırsın: [C] extends [true].
MyAwaited: Recursive infer
type MyAwaited<T extends PromiseLike<any>> =
T extends PromiseLike<infer V> ? (V extends PromiseLike<any> ? MyAwaited<V> : V) : never;
type Thenable = { then: (onfulfilled: (arg: number) => any) => any };
type AwaitedCases = [
Expect<Equal<MyAwaited<Promise<string>>, string>>,
Expect<Equal<MyAwaited<Promise<Promise<number | boolean>>>, number | boolean>>,
Expect<Equal<MyAwaited<Thenable>, number>>,
// @ts-expect-error
MyAwaited<number>,
];
Kolay seviyenin en "orta" sorusu bu. Üç fikir var. Birincisi, Promise yerine PromiseLike: JavaScript'te await, then method'u olan her object'le çalışır ve test listesindeki Thenable bunu ölçüyor. İkincisi, infer V ile promise'in içindeki değeri yakalamak. Üçüncüsü recursion: yakaladığımız değer de bir promise ise MyAwaited'i tekrar çağırıyoruz, ta ki promise olmayan bir değere ulaşana kadar. any burada bilinçli bir tercih; "hangi değeri taşıdığı umurumda değil, yeter ki thenable olsun" demenin kısa yolu.
Concat ve Includes: Spread ve Tam Eşitlik
type Concat<T extends readonly unknown[], U extends readonly unknown[]> = [...T, ...U];
type Includes<T extends readonly unknown[], U> =
T extends readonly [infer F, ...infer R]
? Equal<F, U> extends true
? true
: Includes<R, U>
: false;
const one = [1] as const;
type ConcatCases = [
Expect<Equal<Concat<[], []>, []>>,
Expect<Equal<Concat<typeof one, typeof one>, [1, 1]>>,
Expect<Equal<Concat<["1", 2], [false, boolean]>, ["1", 2, false, boolean]>>,
// @ts-expect-error
Concat<null, undefined>,
];
type IncludesCases = [
Expect<Equal<Includes<["Ada", "Alan", "Grace"], "Alan">, true>>,
Expect<Equal<Includes<["Ada", "Alan", "Grace"], "Linus">, false>>,
Expect<Equal<Includes<[boolean, 2, 3], false>, false>>,
Expect<Equal<Includes<[{ a: "A" }], { readonly a: "A" }>, false>>,
Expect<Equal<Includes<[null], undefined>, false>>,
Expect<Equal<Includes<[1 | 2], 1>, false>>,
];
Concat JavaScript'teki spread'in tip seviyesindeki karşılığı ve tek satır. Includes ise listenin en öğretici sorusu. İlk akla gelen U extends T[number] ? true : false çözümü [boolean, 2, 3] testinde düşüyor: false, boolean'a atanabilir, ama soru "listede tam olarak false var mı?" ve cevap hayır. Atanabilirlik ile eşitlik farklı şeyler. Bu yüzden tuple'ı recursive olarak eleman eleman geziyor ve her elemanı Equal ile karşılaştırıyoruz. readonly object'li, null/undefined'lı ve union'lı testler de aynı farkı ölçüyor.
Sık Düşülen Tuzaklar
Birincisi, Equal'ı basit tanımla yazmak. [X] extends [Y] ? ([Y] extends [X] ? true : false) : false makul görünür ama any'de çöker: any her şeye atanabilir, her şey de any'ye. Bu tanımla NaiveEqual<any, string> true çıkıyor; type-challenges'taki tanım ise false veriyor.
İkincisi, constraint'lerde readonly'yi unutmak. as const ile üretilmiş tuple'lar readonly'dir. Includes'u T extends [infer F, ...infer R] ile yazarsan readonly bir tuple bu kalıba hiç uymaz ve sonuç her zaman false olur. Hata almazsın, sadece yanlış cevap alırsın; en tehlikelisi de bu.
Üçüncüsü, distributive davranışın never'daki etkisi. Çıplak tip parametresine never verirsen conditional type hiç çalışmaz ve sonuç never olur; If<never, "a", "b"> ne "a" ne "b", never verir.
Dördüncüsü, testsiz çözüm yazmak. Tip seviyesindeki kodun hatası sessizdir; yanlış type'ı üreten bir çözüm çoğu zaman hiçbir uyarı vermeden derlenir. Önce Expect satırlarını yaz, sonra çözümü.
Küçük Bir Deneme
★☆☆ Type'ı ne, tahmin et: MyExclude<"a" | "b", string>. Sonra bir Expect<Equal<…>> satırıyla tahminini doğrula.
★★☆ Kolay seviyede kalan iki soruyu çöz: Push<[1, 2], "3"> sonucu [1, 2, "3"], Unshift<[1, 2], 0> sonucu [0, 1, 2] olsun. Önce testleri yaz, sonra çözümleri.
★★★ Son kolay soru: hazır Parameters'ı kullanmadan MyParameters<T> yaz. MyParameters<(a: string, b: number) => void> [a: string, b: number] olmalı. İpucu: infer'i fonksiyon imzasının parametre listesinde kullan.
Bu bulmacalar tip sistemine dair kas geliştiriyor, ama bu kası gerçek projede en çok, var olan bir JavaScript kodunu adım adım tiplendirirken kullanırsın. Bir sonraki yazıda tam olarak bunu konuşacağız: bir JavaScript projesini TypeScript'e kademeli olarak geçirmek.