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

TypeScript'te Variance ve Atanabilirlik: Covariance, Contravariance ve strictFunctionTypes

Mert Özen 3 Kas 2026 8 dk 3 görüntülenme
TypeScript'te Variance ve Atanabilirlik: Covariance, Contravariance ve strictFunctionTypes

Atanabilirliğin yönünü, dönüş tiplerinde covariance'ı, parametrelerde contravariance'ı, strictFunctionTypes'ı, method bivariance'ı, array'lerin açığını ve in/out variance annotation'larını görüyoruz.

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

Önce Tahmin Et

İki interface ve her biri için bir handler fonksiyonu yazdık. Yazının bütün örneklerinde bu iki interface'i kullanacağız:

interface Animal {
  name: string;
}
interface Dog extends Animal {
  bark(): string;
}

let handleAnimal = (animal: Animal) => console.log(animal.name);
let handleDog = (dog: Dog) => console.log(dog.bark());

handleDog = handleAnimal;
handleAnimal = handleDog;

Son iki satırdan biri hata veriyor. Hangisi? Sezgin büyük ihtimalle "her Dog bir Animal'dır, o zaman Dog ile ilgili olan şey Animal ile ilgili olanın yerine geçebilir" diyor. Bu sezgi değerler için doğru; fonksiyonlar için ise tam tersi olabilir. Cevap, atanabilirliğin yönünü konuştuktan sonra.

Atanabilirliğin Bir Yönü Var

Temel seride structural typing'i konuşurken şu kuralı kurmuştuk: bir type'ın beklediği her şeyi taşıyan değer, o type'a atanabilir. Dog, Animal'ın istediği name'i taşıyor; o yüzden Dog, Animal'a atanabilir. Bu ilişkide Dog alt tip (subtype), Animal üst tiptir (supertype). Ok her zaman alt tipten üst tipe doğru: Dog → Animal.

Variance şu soruyu sorar: bu ok, Dog ve Animal'ı içine alan bileşik type'lara nasıl taşınır? Dog[] ile Animal[], () => Dog ile () => Animal, (dog: Dog) => void ile (animal: Animal) => void arasında ok hangi yöne bakar? Üç olası cevap var: aynı yöne (covariance), ters yöne (contravariance) ya da hiçbir yöne (invariance).

Covariance: Aynı Yön

Dönüş tipleriyle başlayalım:

let getDog = (): Dog => ({ name: "Rex", bark: () => "Woof" });
let getAnimal = (): Animal => ({ name: "Generic" });

getAnimal = getDog;

// Hata: Type '() => Animal' is not assignable to type '() => Dog'.
getDog = getAnimal;

getAnimal'ı çağıran kod bir Animal bekler. Ona bir Dog döndürürsen beklentisi fazlasıyla karşılanır, sorun yok. Tersinde ise getDog'u çağıran kod bark'ı olan bir değer bekliyor ve getAnimal bunu garanti etmiyor. Ok aynı yöne bakıyor: Dog → Animal olduğu için () => Dog → () => Animal. Dönüş tipleri, readonly property'ler ve readonly array'ler covariant'tır; yani değer yalnızca dışarı çıkıyorsa, okunuyorsa, alt tipi vermek güvenlidir.

Contravariance: Ters Yön

Parametrelerde ok döner. Tahmin sorusundaki satırları tek tek düşünelim. handleDog = handleAnimal derlenir: handleDog'u çağıran kod ona her zaman bir Dog verecek, handleAnimal ise yalnızca name'e ihtiyaç duyuyor. Bir köpeği, her hayvanla ilgilenebilen bir veterinere götürmek güvenlidir. handleAnimal = handleDog ise hata verir:

error TS2322: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
  Types of parameters 'dog' and 'animal' are incompatible.
    Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

handleAnimal'ı çağıran kod ona herhangi bir hayvan verebilir, mesela bir kedi. Yerine handleDog konduysa, kediye bark() dedirtmeye çalışırız. Yalnızca köpeklere bakan bir veterinere her hayvanı yollayamazsın. Ok ters dönüyor: Dog → Animal iken (animal: Animal) => void → (dog: Dog) => void. Parametreler contravariant'tır.

Akılda tutmanın kısa yolu şu soru: "Bu fonksiyonu kim çağıracak ve ona ne verecek?" Değer fonksiyona giriyorsa ok döner, fonksiyondan çıkıyorsa aynı yönde kalır.

strictFunctionTypes

Bu kontrol her zaman böyle değildi. TypeScript 2.6'ya kadar parametreler bivariant'tı, yani iki yönde de atanabilirlik kabul ediliyordu. TypeScript 2.6 ile gelen strictFunctionTypes ayarı parametreleri contravariant yaptı ve bugün strict ailesinin bir parçası; TS 6.0'dan beri strict varsayılan olarak açık olduğu için bu kontrol de açık. Kapatırsan ne olduğunu görmek öğretici:

let handleAnimal = (animal: Animal) => console.log(animal.name);
let handleDog = (dog: Dog) => console.log(dog.bark());

// Hata: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
handleAnimal = handleDog;

// strictFunctionTypes kapalıyken üstteki satır derlenir ve bu çağrı çöker:
// TypeError: dog.bark is not a function
handleAnimal({ name: "Generic" });

Aynı dosyayı npx tsc --noEmit --strictFunctionTypes false ile denetlersen hata kaybolur, çünkü derleyici iki yönde de atamaya izin verir. Çalıştırdığında ise tam olarak az önce anlattığımız senaryo gerçekleşir: kendini köpek sanan handler'a köpek olmayan bir hayvan gelir. Bu ayarı kapatmak için iyi bir sebep yok.

Method Bivariance

strictFunctionTypes'ın bilinçli bir istisnası var: method sözdizimiyle yazılmış imzalar strict modda bile bivariant kalır. Aynı sözleşmeyi iki şekilde yazalım:

interface AnimalHandler {
  handle(animal: Animal): void;
}

const dogOnly: AnimalHandler = {
  handle(dog: Dog) {
    console.log(dog.bark());
  },
};

dogOnly.handle({ name: "Generic" }); // TypeError: dog.bark is not a function

Kod strict modda hatasız derleniyor ve çalışma zamanında çöküyor. handle(animal: Animal): void bir method imzası olduğu için derleyici parametreyi iki yönde de kabul etti. Property sözdizimiyle yazınca kontrol geri gelir:

interface StrictAnimalHandler {
  handle: (animal: Animal) => void;
}

const strictDogOnly: StrictAnimalHandler = {
  // Hata: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
  handle: (dog: Dog) => console.log(dog.bark()),
};

İstisnanın sebebi array'ler. Array<T>'nin push(...items: T[]) gibi method'ları T'yi parametre olarak alır. Method'lar da contravariant olsaydı Dog[], Animal[]'a atanamazdı ve günlük kodun büyük kısmı hata verirdi. TypeScript burada pratikliği seçmiş. Senin için çıkarılacak ders basit: callback ve handler tanımlarken, tip güvenliği önemliyse property sözdizimini kullan.

Invariance ve Array'lerin Sırrı

Bir type parametresi hem okunuyor hem yazılıyorsa iki kural birbirini kilitler:

type Box<T> = {
  get: () => T;
  set: (value: T) => void;
};

declare let animalBox: Box<Animal>;
declare let dogBox: Box<Dog>;

// Hata: Type 'Box<Dog>' is not assignable to type 'Box<Animal>'.
animalBox = dogBox;

// Hata: Type 'Box<Animal>' is not assignable to type 'Box<Dog>'.
dogBox = animalBox;

get covariance istiyor, set contravariance. İkisi birlikte hiçbir yöne izin vermiyor: Box invariant. Değiştirilebilir bir array de aslında böyle bir kutu; hem elemanlarını okursun hem içine yazarsın. Ama az önce gördüğümüz method bivariance sayesinde TypeScript array'leri covariant kabul eder ve bunun bir bedeli var:

interface Cat extends Animal {
  meow(): string;
}

const dogs: Dog[] = [{ name: "Rex", bark: () => "Woof" }];
const animals: Animal[] = dogs;
const tom: Cat = { name: "Tom", meow: () => "Meow" };
animals.push(tom);

for (const dog of dogs) {
  console.log(dog.bark());
}
// Woof
// TypeError: dog.bark is not a function

animals ve dogs aynı array. Animal[] üzerinden içine bir kedi koyduk, Dog[] üzerinden okurken de onu köpek sandık. Derleyici hiçbir satıra itiraz etmedi. Bu, TypeScript'in bilinçli olarak kabul ettiği bir açık. Bir fonksiyon array'i sadece okuyacaksa parametreyi readonly Animal[] diye yaz: readonly array'e push yapılamadığı için orada covariance gerçekten güvenlidir.

Variance Annotation'ları: in ve out

TypeScript normalde bir type parametresinin variance'ını yapıdan kendisi çıkarır. TypeScript 4.7 ile bunu açıkça yazabiliyoruz: out covariant, in contravariant, in out invariant demektir:

interface Producer<out T> {
  produce: () => T;
}
interface Consumer<in T> {
  consume: (value: T) => void;
}
interface Store<in out T> {
  get: () => T;
  set: (value: T) => void;
}

// Hata: Type 'WrongProducer<sub-T>' is not assignable to type 'WrongProducer<super-T>' as implied by variance annotation.
interface WrongProducer<out T> {
  consume: (value: T) => void;
}

Annotation'lar derleyicinin bulduğu variance'la çelişirse TS2636 alırsın. WrongProducer, T'yi yalnızca parametrede kullanıyor; yani yapısı contravariant, ama biz ona out dedik. Mesajdaki sub-T ve super-T, derleyicinin denemek için uydurduğu alt ve üst tiplerdir.

Annotation'lar yalnızca interface, class ve object ya da fonksiyon type'ı tanımlayan type alias'larda yazılabilir; başka bir yerde TS2637 ya da TS1274 alırsın. Ne zaman kullanmalı? Nadiren. Bir library'nin public type'larında niyeti belgelemek ve çok derin recursive type'larda derleyicinin variance hesaplamasını kısaltmak için faydalıdır. Bir hatayı susturmak için kullanma; annotation yapıyla tutarlı olmak zorunda ve en fazla type'ı daha katı yapabilir.

Sık Düşülen Tuzaklar

İlki array covariance'ına güvenmek. Dog[]'u Animal[] parametresi alan bir fonksiyona vermek derlenir; fonksiyon array'e yazıyorsa çalışma zamanında az önceki kediyle karşılaşırsın. Sadece okuyan fonksiyonlarda readonly array parametresi hem niyeti belgeler hem açığı kapatır.

İkincisi method sözdizimi. onSelect(item: Product): void ile onSelect: (item: Product) => void aynı şeyi söylüyor gibi görünür, ama ilki strict modda bile parametre kontrolünü gevşetir.

Üçüncüsü readonly property'lerin atanabilirliği. Bir önceki yazının sonunda DeepReadonly<Settings> değerinin Settings'e atanamadığını gördük; itiraz yalnızca readonly array yüzündendi. Readonly bir property ise değiştirilebilir olanına sessizce atanır: { readonly name: string } type'ındaki bir değeri { name: string } bekleyen yere verebilir ve sonra değiştirebilirsin. readonly bir derleme zamanı sözüdür, atanabilirlikte bir yön oluşturmaz.

Küçük Bir Deneme

★☆☆ Tahmin et: let fromDog: (dog: Dog) => Animal; ve let fromAnimal: (animal: Animal) => Dog; için fromDog = fromAnimal mı derlenir, fromAnimal = fromDog mı? Parametre ve dönüş tipini ayrı ayrı düşün.

★★☆ interface Listener<T> { notify(value: T): void } tanımında Listener<Dog> değerini Listener<Animal> değişkenine ata. Derlenir mi? Sonra notify'ı property sözdizimiyle yaz ve tekrar dene.

★★★ type Getter<out T> = () => T; yaz ve derlendiğini gör. Sonra out'u in yap ve TS2636 mesajını oku: derleyici hangi atamanın neden tutmadığını söylüyor?

Bu yazıda structural typing'in hangi yönlerde atamaya izin verdiğini konuştuk. Peki iki type'ın birbirine hiç atanamamasını istediğimizde ne olacak? UserId ve OrderId ikisi de string ise TypeScript onları ayırt etmez. Bir sonraki yazıda structural typing'in bu sınırını branded type'larla aşacağız: aynı şekle sahip type'ları birbirinden ayıran brand'ler, unique symbol ve doğrulama fonksiyonlarıyla brand üretmek.