← Kursa Dön
📄 Text · 30 min

Generics Derinlemesine

Neden Generics?

Diyelim ki bir kutu yapıyorsun. Bu kutu bazen ayakkabı, bazen kitap, bazen mücevher taşıyacak. Her seferinde farklı bir kutu mu yapacaksın? Tabii ki hayır — tek bir kutu kalıbı yaparsın, içine ne koyulacağını o an belirlersin. İşte TypeScript'te Generics tam olarak bu: "içeriği sonra belli olacak" bir kalıp.

Önceki derslerde tip sisteminin temellerini öğrendin. string, number, boolean gibi tipleri fonksiyonlara verdik, interface'ler ve type alias'lar tanımladık. Ama bir noktada fark edeceksin ki aynı mantığı farklı tipler için tekrar tekrar yazıyorsun. Bir fonksiyon string[] için de çalışmalı, number[] için de, User[] için de. Her seferinde yeni bir fonksiyon mu yazacaksın? İşte burada Generics devreye giriyor.

Generics, tip seviyesinde soyutlama yapmanı sağlar. Nasıl fonksiyonlar değerleri soyutlıyorsa, Generics de tipleri soyutlar. Fonksiyona parametre olarak değer verirsin — Generic'e parametre olarak tip verirsin.


Generic Fonksiyonlar: İlk Adım

En basit örnekle başlayalım. Bir fonksiyon yazacaksın: verdiğin değeri geri döndürsün. Tüm tipler için çalışsın:

// ❌ Kötü yol: any kullanmak
function identity(value: any): any {
  return value;
}

const result = identity("merhaba"); // result'ın tipi: any 😱
// TypeScript artık "merhaba" string olduğunu bilmiyor!

any kullandığında TypeScript gözlerini kapatır. Tip güvenliği sıfıra iner. Peki her tip için ayrı fonksiyon mu yazacağız?

// ❌ Kötü yol: her tip için ayrı fonksiyon
function identityString(value: string): string { return value; }
function identityNumber(value: number): number { return value; }
function identityBoolean(value: boolean): boolean { return value; }
// 100 tip için 100 fonksiyon mu? Saçmalık.

İşte Generic çözümü:

// ✅ Generic fonksiyon — tek kalıp, sonsuz tip
function identity<T>(value: T): T {
  return value;
}

// Kullanım: tip parametresi açıkça belirtilebilir
const str = identity<string>("merhaba");  // str: string
const num = identity<number>(42);          // num: number

// Ya da TypeScript çıkarım (inference) yapabilir
const auto = identity("merhaba");          // auto: string ✨

<T> burada bir tip parametresi. Fonksiyondaki normal parametreler gibi düşün: value bir değer parametresi, T bir tip parametresi. Fonksiyon çağrılınca T yerine gerçek tip geçer.

💡 İpucu: T bir konvansiyon — "Type"ın kısaltması. İstediğin ismi verebilirsin: <Element>, <Item>, <Data>. Ama tek harfli konvansiyon yaygın: T (Type), K (Key), V (Value), E (Element), R (Result).

Birden Fazla Tip Parametresi

Bazen tek tip yetmez. İki farklı tipi ilişkilendirmek isteyebilirsin:

// İki farklı tipten oluşan bir çift (pair) döndür
function makePair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

const pair1 = makePair("isim", 42);        // [string, number]
const pair2 = makePair(true, [1, 2, 3]);   // [boolean, number[]]

// Gerçek dünya: bir API'den gelen veri ve hata bilgisi
function apiResponse<TData, TError>(
  data: TData | null,
  error: TError | null
): { data: TData | null; error: TError | null } {
  return { data, error };
}

const success = apiResponse<User, string>({ id: 1, name: "Ali" }, null);
const failure = apiResponse<User, string>(null, "Kullanıcı bulunamadı");

Generic Kısıtlamalar (Constraints)

Kutu analojimize dönelim: "Bu kutu her şeyi taşıyabilir" demek bazen çok gevşek. Mesela kutunun sadece length özelliği olan şeyleri (string, array) taşımasını istiyorsan? İşte constraints — kısıtlamalar — devreye giriyor.

// ❌ Problem: T'nin length özelliği olduğunu bilmiyoruz
function logLength<T>(value: T): void {
  console.log(value.length); // ❌ Hata: Property 'length' does not exist on type 'T'
}

TypeScript haklı — T herhangi bir tip olabilir. number'ın length'i yok. Kısıtlama ekleyelim:

// ✅ extends ile kısıtlama: T, en azından length'e sahip olmalı
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(value: T): T {
  console.log(`Uzunluk: ${value.length}`);
  return value;
}

logLength("merhaba");     // ✅ string'in length'i var
logLength([1, 2, 3]);     // ✅ array'in length'i var
logLength({ length: 10, name: "test" }); // ✅ length özelliği var

logLength(42);            // ❌ Hata: number'da length yok

T extends HasLength demek: "T ne olursa olsun, en azından length: number özelliği olmalı." Bu bir kontrat — minimum gereksinimi belirliyorsun.

keyof ile Kısıtlama

Çok güçlü bir pattern: bir nesnenin sadece var olan key'lerine erişimi garanti etmek.

// Nesneden güvenli bir şekilde değer al
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Ali", age: 25, email: "ali@test.com" };

const name = getProperty(user, "name");    // string ✅
const age = getProperty(user, "age");      // number ✅
getProperty(user, "phone");                // ❌ Hata: "phone" keyof User değil

Bu pattern'ı her yerde göreceksin. T[K] bir indexed access type — T tipinin K key'ine karşılık gelen tipi döndürür. user["name"]'in tipi string, user["age"]'in tipi number — TypeScript bunu biliyor.

⚠️ Dikkat: keyof operatörü bir union döndürür. keyof typeof user = "name" | "age" | "email". Bu yüzden K extends keyof T dediğinde K sadece bu üç string literal'den biri olabilir.


Generic Class ve Interface

Fonksiyonlarla sınırlı değiliz. Class ve Interface'ler de generic olabilir. Düşünsene: bir Stack veri yapısı — her tip için çalışmalı:

class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }
}

// String stack
const stringStack = new Stack<string>();
stringStack.push("ilk");
stringStack.push("ikinci");
console.log(stringStack.pop()); // "ikinci"

// Number stack — aynı class, farklı tip
const numberStack = new Stack<number>();
numberStack.push(42);
numberStack.push(100);
console.log(numberStack.peek()); // 100

// Hatta User stack!
interface User {
  id: number;
  name: string;
}
const userStack = new Stack<User>();
userStack.push({ id: 1, name: "Ali" });

Generic Interface

// API yanıtı için generic bir yapı
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

// Kullanıcı listesi yanıtı
type UserListResponse = ApiResponse<User[]>;
// { data: User[], status: number, message: string, timestamp: Date }

// Tek kullanıcı yanıtı
type SingleUserResponse = ApiResponse<User>;
// { data: User, status: number, message: string, timestamp: Date }

// Sayfalanmış (paginated) yanıt — Generic iç içe kullanılabilir
interface PaginatedResponse<T> extends ApiResponse<T[]> {
  page: number;
  totalPages: number;
  totalItems: number;
}

function fetchUsers(): Promise<PaginatedResponse<User>> {
  // API çağrısı...
  return fetch("/api/users").then(res => res.json());
}

💡 İpucu: Generic'ler varsayılan tip alabilir — tıpkı fonksiyon parametrelerinin varsayılan değer alması gibi:

>

``typescript interface Container<T = string> { value: T; } const c1: Container = { value: "merhaba" }; // T = string (varsayılan) const c2: Container<number> = { value: 42 }; // T = number (açıkça belirtildi) ``


Conditional Types: Tip Seviyesinde if/else

Normal kodda if/else ile karar verirsin. TypeScript'in tip seviyesinde de bir karar mekanizması var: Conditional Types. Syntax'ı ternary operatöre benzer:

// Syntax: T extends U ? X : Y
// "T, U'ya atanabilir mi? Evetse X, hayırsa Y"

type IsString<T> = T extends string ? "evet" : "hayır";

type A = IsString<string>;   // "evet"
type B = IsString<number>;   // "hayır"
type C = IsString<"hello">;  // "evet" (string literal, string'e atanabilir)

Bu basit görünebilir ama gerçek gücü daha karmaşık senaryolarda ortaya çıkar:

// Bir tipin array olup olmadığını kontrol et ve eleman tipini çıkar
type UnwrapArray<T> = T extends Array<infer U> ? U : T;

type D = UnwrapArray<string[]>;    // string
type E = UnwrapArray<number[]>;    // number
type F = UnwrapArray<boolean>;     // boolean (array değil, kendisi döner)

Burada infer U ile tanıştın — birazdan detaylı bakacağız. Ama şimdilik şunu bil: infer, conditional type içinde bir tipi yakalayıp çıkarmak için kullanılır. "Array'in içindeki tip ne? Onu U olarak yakala ve döndür."

Distributive Conditional Types

Conditional Type'lara union verdiğinde, TypeScript her bir üyeye ayrı ayrı uygular:

type ToArray<T> = T extends any ? T[] : never;

// Union verildiğinde dağıtılır (distribute edilir)
type G = ToArray<string | number>;
// = (string extends any ? string[] : never) | (number extends any ? number[] : never)
// = string[] | number[]

// Eğer dağıtılmasını İSTEMİYORSAN, köşeli parantez kullan:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type H = ToArrayNonDist<string | number>;
// = (string | number)[]   — tek bir array tipi

⚠️ Dikkat: Distributive davranış sadece naked type parameter (çıplak tip parametresi) kullandığında olur. T extends ... distributive, [T] extends ... değil. Bu ince ama kritik bir fark.


Mapped Types: Tip Dönüştürme Makinesi

Diyelim ki bir nesne tipin var ve her property'sini readonly yapmak istiyorsun. Ya da hepsini opsiyonel yapmak. Tek tek mi yazacaksın? Hayır — Mapped Types her property üzerinde döngü kurar ve dönüştürür.

Düşün ki bir fotoğraf makinesi var: nesneyi çekiyor, her bir parçaya bir filtre uyguluyor, sonucu veriyor. Mapped Type o filtre.

// Temel syntax: { [K in keyof T]: DönüştürülmüşTip }

// Tüm property'leri opsiyonel yap (Partial'ın arkası!)
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

// Tüm property'leri readonly yap
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

interface User {
  id: number;
  name: string;
  email: string;
}

type PartialUser = MyPartial<User>;
// { id?: number; name?: string; email?: string; }

type ReadonlyUser = MyReadonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }

Property'leri Yeniden Adlandırma (Key Remapping)

TypeScript 4.1+ ile property isimlerini de dönüştürebilirsin:

// Her property'nin başına "get" ekle
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// {
//   getId: () => number;
//   getName: () => string;
//   getEmail: () => string;
// }

Modifier Ekleme/Çıkarma

+ ve - ile modifier ekleyebilir veya çıkarabilirsin:

// readonly'yi KALDIR
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

// opsiyonelliği KALDIR (tüm property'ler zorunlu)
type Concrete<T> = {
  [K in keyof T]-?: T[K];
};

type MutableUser = Mutable<ReadonlyUser>;
// { id: number; name: string; email: string; } — readonly gitti

type RequiredUser = Concrete<PartialUser>;
// { id: number; name: string; email: string; } — ? gitti

Utility Types: TypeScript'in Hazır Araç Çantası

TypeScript, en yaygın Generic tip dönüşümlerini yerleşik Utility Types olarak sunar. Bunları sıfırdan yazmak yerine direkt kullanabilirsin. Ama nasıl çalıştığını bilmek önemli — bu yüzden her birini hem kullanımını hem arkasındaki Mapped Type'ı göreceğiz.

Partial\<T\>

Tüm property'leri opsiyonel yapar. Güncelleme (update) operasyonlarında altın değerinde:

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

// Arkası: type Partial<T> = { [K in keyof T]?: T[K] }

function updateUser(id: number, updates: Partial<User>): User {
  const existingUser = getUserById(id); // mevcut kullanıcıyı getir
  return { ...existingUser, ...updates };
}

// Sadece email güncellemek istiyorsun — diğer alanları vermek zorunda değilsin
updateUser(1, { email: "yeni@email.com" });         // ✅
updateUser(1, { name: "Ali", age: 26 });             // ✅
updateUser(1, { phone: "555" });                      // ❌ phone, User'da yok

Required\<T\>

Partial'ın tersi — tüm property'leri zorunlu yapar:

interface Config {
  host?: string;
  port?: number;
  debug?: boolean;
}

// Arkası: type Required<T> = { [K in keyof T]-?: T[K] }

// Config'i doğruladıktan sonra tüm alanların dolu olduğunu garanti et
function validateConfig(config: Config): Required<Config> {
  return {
    host: config.host ?? "localhost",
    port: config.port ?? 3000,
    debug: config.debug ?? false,
  };
}

const validated = validateConfig({ host: "example.com" });
console.log(validated.port); // 3000 — kesinlikle var, ? yok

Pick\<T, K\>

Bir tipten sadece belirli property'leri seç:

// Arkası: type Pick<T, K extends keyof T> = { [P in K]: T[P] }

// Kullanıcı listesinde sadece isim ve email göster
type UserPreview = Pick<User, "name" | "email">;
// { name: string; email: string; }

// API'den dönen veride sadece gerekli alanları al
function renderUserCard(user: Pick<User, "name" | "email" | "age">) {
  return `${user.name} (${user.age}) - ${user.email}`;
}

Omit\<T, K\>

Pick'in tersi — belirli property'leri çıkar:

// Arkası: type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>

// Yeni kullanıcı oluştururken id server tarafından verilir
type CreateUserDto = Omit<User, "id">;
// { name: string; email: string; age: number; }

function createUser(data: CreateUserDto): User {
  return {
    id: generateId(),  // id'yi biz üretiyoruz
    ...data,
  };
}

createUser({ name: "Ali", email: "ali@test.com", age: 25 }); // ✅ id yok, doğru

Record\<K, V\>

Belirli key'lerle bir nesne tipi oluşturur:

// Arkası: type Record<K extends keyof any, T> = { [P in K]: T }

// Her HTTP durum kodu için bir mesaj
type StatusMessages = Record<200 | 404 | 500, string>;
// { 200: string; 404: string; 500: string; }

const messages: StatusMessages = {
  200: "Başarılı",
  404: "Bulunamadı",
  500: "Sunucu hatası",
};

// Daha dinamik kullanım: role bazlı izinler
type Role = "admin" | "editor" | "viewer";
type Permissions = Record<Role, string[]>;

const permissions: Permissions = {
  admin: ["read", "write", "delete", "manage"],
  editor: ["read", "write"],
  viewer: ["read"],
};

Diğer Önemli Utility Types

// Exclude: Union'dan belirli tipleri çıkar
type AllTypes = string | number | boolean | null;
type NonNull = Exclude<AllTypes, null>;          // string | number | boolean

// Extract: Union'dan belirli tipleri seç
type OnlyStrNum = Extract<AllTypes, string | number>; // string | number

// NonNullable: null ve undefined'ı çıkar
type Clean = NonNullable<string | null | undefined>; // string

// ReturnType: Fonksiyonun dönüş tipini al
function fetchData() {
  return { users: [], total: 0 };
}
type FetchResult = ReturnType<typeof fetchData>;
// { users: never[]; total: number; }

// Parameters: Fonksiyonun parametre tiplerini tuple olarak al
function greet(name: string, age: number): string {
  return `${name}, ${age}`;
}
type GreetParams = Parameters<typeof greet>; // [string, number]

Gerçek Dünya Örneği: Type-Safe Event Emitter

Şimdi öğrendiğimiz her şeyi birleştirelim. Gerçek dünyada karşılaşacağın bir pattern — tipi güvenli bir event emitter:

// Event haritası: her event isminin payload tipi belli
interface EventMap {
  userCreated: { id: number; name: string };
  userDeleted: { id: number };
  orderPlaced: { orderId: string; total: number };
  notification: { message: string; level: "info" | "warn" | "error" };
}

// Type-safe Event Emitter
class TypedEventEmitter<TEvents extends Record<string, any>> {
  private listeners: {
    [K in keyof TEvents]?: Array<(payload: TEvents[K]) => void>;
  } = {};

  // Event dinle — sadece tanımlı event isimleri kabul edilir
  on<K extends keyof TEvents>(
    event: K,
    listener: (payload: TEvents[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }

  // Event yayınla — payload tipi event ismine göre zorunlu
  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    const eventListeners = this.listeners[event];
    if (eventListeners) {
      eventListeners.forEach(listener => listener(payload));
    }
  }

  // Listener kaldır
  off<K extends keyof TEvents>(
    event: K,
    listener: (payload: TEvents[K]) => void
  ): void {
    const eventListeners = this.listeners[event];
    if (eventListeners) {
      this.listeners[event] = eventListeners.filter(l => l !== listener);
    }
  }
}

// Kullanım — tam tip güvenliği
const emitter = new TypedEventEmitter<EventMap>();

emitter.on("userCreated", (payload) => {
  // payload otomatik olarak { id: number; name: string }
  console.log(`Yeni kullanıcı: ${payload.name}`);
});

emitter.emit("userCreated", { id: 1, name: "Ali" });      // ✅
emitter.emit("userCreated", { id: 1 });                    // ❌ name eksik
emitter.emit("unknownEvent", {});                           // ❌ bu event tanımlı değil

emitter.on("orderPlaced", (payload) => {
  // payload otomatik olarak { orderId: string; total: number }
  console.log(`Sipariş: ${payload.orderId}, Tutar: ${payload.total}`);
});

Bu örnekte:

  • Generic class (TypedEventEmitter<TEvents>)

  • Constraint (TEvents extends Record<string, any>)

  • keyof ve indexed access (K extends keyof TEvents, TEvents[K])

  • Mapped type (listeners nesnesinin tipi)

Hepsi bir arada, gerçek dünya problemi çözüyor.


Yaygın Hatalar ve Tuzaklar

1. Gereksiz Generic Kullanımı

// ❌ Generic burada gereksiz — T sadece bir kez kullanılıyor
function bad<T>(value: T): void {
  console.log(value);
}

// ✅ Generic gerekmiyorsa kullanma
function good(value: unknown): void {
  console.log(value);
}

Kural: Generic tip parametresi en az iki yerde kullanılmalı (parametrede, dönüş tipinde, veya başka parametrede). Sadece bir yerde kullanıyorsan, muhtemelen gereksiz.

2. any ile Generic Karıştırma

// ❌ Generic'in amacını yok eder
function process<T>(items: T[]): any {
  return items[0];
}

// ✅ Dönüş tipi de T olmalı
function process<T>(items: T[]): T | undefined {
  return items[0];
}

3. Constraint Unutmak

// ❌ T'nin toString'i olduğu garanti değil (aslında her şeyin var ama...)
function stringify<T>(value: T): string {
  return value.toString(); // Bu çalışır ama...
}

// ✅ Eğer spesifik bir özelliğe erişiyorsan, constraint koy
interface Stringifiable {
  toString(): string;
}

function stringify<T extends Stringifiable>(value: T): string {
  return value.toString();
}

4. Over-Engineering

// ❌ Çok karmaşık, anlaşılmaz
type DeepPartialReadonlyNullable<T> = {
  readonly [K in keyof T]?: T[K] extends object
    ? DeepPartialReadonlyNullable<T[K]> | null
    : T[K] | null;
};

// ✅ Basit tut, ihtiyaç oldukça karmaşıklaştır
type UpdateDto<T> = Partial<T>;

💡 İpucu: Generic tip bir araçtır — her yerde kullanmak zorunda değilsin. "Bu tipi iki farklı yerde, farklı tiplerle kullanacak mıyım?" sorusuna evet diyorsan Generic kullan. Hayırsa, somut (concrete) tip yaz.


Özet

  • Generic, tip seviyesinde parametreleme yapar — tek kalıp, sonsuz tip.

  • Tip parametreleri (<T>) fonksiyonlara, class'lara, interface'lere eklenebilir.

  • Constraints (extends) ile tip parametrelerini kısıtlayabilirsin — "en azından şu özelliğe sahip olmalı."

  • Conditional Types (T extends U ? X : Y) tip seviyesinde karar mekanizması sağlar.

  • Mapped Types ({ [K in keyof T]: ... }) her property üzerinde döngü kurarak tip dönüşümü yapar.

  • Utility Types (Partial, Required, Pick, Omit, Record...) yerleşik Generic tip dönüşümleridir — hepsinin arkasında Mapped Types var.

  • Generic'leri sadece gerektiğinde kullan — gereksiz karmaşıklıktan kaçın.