Yazılarımız

Veri Akademi

TYPESCRİPT İLERİ TİPLER: GENERİCS, CONDİTİONAL TYPES VE UTİLİTY TYPES

TypeScript’te “tipler” sadece derleyicinin şikâyet ettiği bir alan değil; doğru kullanıldığında tasarım kararlarını netleştiren, API’leri sadeleştiren ve refactor süresini ciddi biçimde kısaltan bir araçtır. Özellikle büyüyen kod tabanlarında ileri tipler devreye girdiğinde, tekrar eden kalıpları tek noktada tanımlayıp hatayı daha yazarken yakalamak mümkün olur.

Bu yazıda generics, conditional types ve hazır gelen utility types ailesini birlikte ele alacağız. Sadece “nasıl yazılır” değil; “nerede değer katar, nerede karmaşıklık üretir” sorusuna da pratik cevaplar vereceğiz. Hedef, daha güvenli fonksiyon imzaları, daha tutarlı domain modelleri ve daha öngörülebilir kütüphane yüzeyleri kurmak.

Örnekler gerçekçi olacak: veri getiren bir servis katmanı, dönüş tipleri üzerinde koşullu mantık, DTO seçimleri ve yeniden kullanılabilir tip şablonları. Yazının sonunda, öğrendiklerinizi kendi projelerinizde sistematik şekilde uygulayabilmeniz için bir kontrol listesi de bulacaksınız.

Monitörde TypeScript kodu, generics ve tip parametreleriyle bir API taslağı

Primary keyword: TypeScript ileri tipler neden önemli?

“TypeScript ileri tipler” dediğimizde kastımız; tipleri yalnızca alan tanımlarında değil, davranışı yöneten bir sözleşme gibi kullanmaktır. Örneğin aynı fonksiyon farklı parametrelerle çağrıldığında farklı türde sonuçlar üretiyorsa, bunu yorumlara bırakmak yerine tip sistemiyle ifade edebiliriz. Bu sayede IDE otomatik tamamlama, refactor ve hata yakalama daha doğru çalışır.

İleri tipler iki kritik noktada öne çıkar: (1) ortak kalıpları tekleştirmek ve (2) yanlış kullanımı daha erken engellemek. Bir modül içinde “benzer ama küçük farkları olan” on tip tanımı görüyorsanız, büyük ihtimalle generics veya mapped type ile sadeleşme alanı vardır. Aynı şekilde fonksiyonların dönüşleri parametreye göre değişiyorsa, conditional types ile daha iyi bir sözleşme kurabilirsiniz.

Tip sistemini tasarım aracı olarak konumlamak

Tipler, “gerçekte runtime’da yok” diye göz ardı edildiğinde, büyük projelerde davranış dokümantasyonu dağılır. Oysa tip imzaları, ekip içi iletişimi güçlendirir. Bir fonksiyonun kontratını okumak, çoğu zaman implementasyonu okumaktan daha hızlıdır. Bu yüzden tipler; kodun niyetini, sınırlarını ve izin verdiği kombinasyonları anlatan bir arayüz gibidir.

Derleyici geri bildirimini stratejik kullanmak

Derleyici hatasını “düzeltilmesi gereken kırmızı” olarak değil, “yanlış kullanım senaryosunu yakaladım” şeklinde düşünmek işe yarar. Bu yaklaşımda amaç, en sık yapılan hatayı mümkün olduğunca erken yakalatmak ve API’yi yanlış kullanmayı zorlaştırmaktır. İleri tipler, bu stratejiyi sürdürülebilir kılar.

Generics: Tek bir çözümle çok senaryo

Generics, bir fonksiyonun veya tipin “hangi türle çalışacağını” çağıranın belirlemesini sağlar. Böylece aynı implementasyonla birçok veri tipini desteklerken, tür güvenliğini korursunuz. Klasik örnek: listeyi map etmek, API yanıtını sarmalamak, depo (repository) katmanında ortak CRUD arayüzü tanımlamak.

Generic fonksiyonlar ve tür çıkarımı

TypeScript çoğu zaman tip parametresini sizin yerinize çıkarır. Bu, kullanım ergonomisini artırır ama “yanlış çıkarım” riskini de beraberinde getirir. İyi bir generic tasarımda, tip parametresi hem doğru çıkarılır hem de çağıran gerektiğinde açıkça belirtebilir.

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: { message: string; code?: string } };

function wrapOk<T>(data: T): ApiResult<T> {
  return { ok: true, data };
}

function wrapErr(message: string, code?: string): ApiResult<never> {
  return { ok: false, error: { message, code } };
}

// tür çıkarımı
const r1 = wrapOk({ id: 1, name: 'Ada' }); // ApiResult<{ id: number; name: string }>

// açık tip parametresi
const r2 = wrapOk<number[]>([1, 2, 3]);

Bu yapı, farklı endpoint’lerin farklı payload’larını tek bir sonuç formatında birleştirir. Ayrıca hata durumunda data alanını “asla gelmez” diye never ile netleştirerek kullanım tarafında daha doğru kontrol akışı kurmanızı sağlar.

Generic constraints ve varsayılanlar

Her generic “her şeye açık” olmak zorunda değil. Bazen parametrenin belirli alanlara sahip olmasını istersiniz. extends ile constraint ekleyerek bu şartı garanti edersiniz. Ayrıca tip parametrelerine varsayılan değer atamak, opsiyonel kullanım sağlar.

type HasId = { id: string };

function indexById<T extends HasId>(items: T[]): Record<string, T> {
  return items.reduce((acc, it) => {
    acc[it.id] = it;
    return acc;
  }, {} as Record<string, T>);
}

type Page<T = unknown> = {
  items: T[];
  total: number;
  page: number;
};

const users = indexById([{ id: 'u1', name: 'Ada' }, { id: 'u2', name: 'Linus' }]);

Bu örnekte indexById, id alanı olmayan bir öğe listesiyle çağrılamaz. Böylece runtime’da yaşanacak “undefined key” sorununu, daha yazarken önlersiniz. Page<T = unknown> ise, tip parametresi verilmediğinde esnek ama güvenli bir varsayılan sunar.

Conditional Types: Parametreye göre tip davranışı

Conditional types, “A tipi B’yi sağlıyorsa X, sağlamıyorsa Y” mantığını tip seviyesinde kurar. Burada hedef, overloading sayısını azaltmak ve dönüş tiplerini daha doğru ifade etmektir. Özellikle kütüphane yazarken, tek bir fonksiyon imzasıyla birçok senaryoyu kapsamak için çok güçlüdür.

Basit koşullu tipler ve distributive davranış

En temel form şu şekildedir: T extends U ? X : Y. Burada dikkat edilmesi gereken nokta, T bir union ise koşulun “dağıtıcı” çalışabilmesidir. Bu çoğu zaman avantajdır; ama bazen istemeden karmaşık sonuçlar da üretebilir.

type IsString<T> = T extends string ? true : false;

type A = IsString<'x'>;       // true
type B = IsString<42>;        // false
type C = IsString<string | 1>;// true | false (distributive)

// dağıtımı kapatma tekniği
type IsStringNoDist<T> = [T] extends [string] ? true : false;
type D = IsStringNoDist<string | 1>; // false

Union üzerinde dağıtım bazen istediğiniz şeydir (örneğin filtreleme), bazen de değildir (örneğin bütün union için tek karar). Köşeli parantez hilesiyle dağıtımı kapatarak daha kontrollü sonuç alırsınız.

Bir editörde extends ve infer kullanılan örneklerle tip dönüşümlerinin izlenmesi

infer ile tip çıkarımı: dönüşleri parçalara ayırmak

infer, bir tipin içinden “bir parçayı yakalayıp isimlendirme” imkânı verir. En sık kullanım alanı; fonksiyon dönüş tipini, promise içindeki değeri, array eleman tipini veya nested yapıların alt tiplerini çekmektir. Bu sayede “tipten tipe dönüştürücü” araçlar yazabilirsiniz.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type UnwrapArray<T> = T extends (infer U)[] ? U : T;

type P1 = UnwrapPromise<Promise<number>>; // number
type P2 = UnwrapPromise<string>;          // string

type A1 = UnwrapArray<string[]>;          // string
type A2 = UnwrapArray<number>;            // number

Bu yaklaşım, servis katmanında “async mi sync mi” gibi farkları tek bir yardımcı tipe indirmenize yardımcı olur. Örneğin bir fonksiyon bazen değer, bazen promise döndürüyorsa, tüketen tarafın tip karmaşası yaşamaması için UnwrapPromise benzeri bir araç, daha temiz bir kontrat sağlar.

Utility Types: Kütüphanenin elinizin altındaki alet çantası

TypeScript’in yerleşik utility types seti, günlük işlerin çoğunu hızlıca çözer. Ama burada kritik nokta, “her yerde kullanmak” değil; doğru yerlerde standartlaşmaktır. Özellikle DTO’lar, form modelleri, API güncelleme payload’ları ve seçici export’lar için çok değerlidir.

Partial, Required, Readonly ile yaşam döngüsü modellemek

Bir varlığın yaşam döngüsünü düşünün: yaratılırken bazı alanlar zorunlu, güncellenirken alanların çoğu opsiyonel, cache’e yazarken ise değişmez olabilir. Bu değişimleri tek tek manuel yazmak yerine utility types ile netleştirebilirsiniz.

  • Partial<T>: Alanların tamamını opsiyonel yapar; patch endpoint’leri için idealdir.
  • Required<T>: Opsiyonelleri zorunluya çevirir; doğrulama sonrası aşamalar için kullanışlıdır.
  • Readonly<T>: Alanları salt okunur yapar; immutable veri katmanlarında işe yarar.

Örneğin UpdateUserDto = Partial<UserDto> gibi bir tanım, update payload’ını tek satırda ifade eder. Ancak burada dikkat: “Partial her şeyi serbest bırakır” ve yanlış alanların da güncellenmesine kapı aralayabilir. Bu nedenle çoğu zaman Pick/Omit ile kapsamı daraltmak daha iyi olur.

Pick, Omit, Record ile tip seçmek ve üretmek

Pick ve Omit özellikle API sözleşmeleri için kurtarıcıdır. Domain modelinizde bulunan ama dışarı açmak istemediğiniz alanlar (örneğin passwordHash, internalFlags) olduğunda, tek bir kaynak tipten güvenli alt tipler üretebilirsiniz.

type User = {
  id: string;
  email: string;
  name: string;
  passwordHash: string;
  createdAt: string;
  role: 'admin' | 'user';
};

type PublicUser = Omit<User, 'passwordHash'>;
type UserSummary = Pick<User, 'id' | 'name' | 'role'>;

type FeatureFlags = Record<'billing' | 'search' | 'ai', boolean>;

const flags: FeatureFlags = { billing: true, search: false, ai: true };

Bu yaklaşım, “tek kaynaktan türetme” sayesinde drift riskini azaltır. User modeline yeni bir alan eklendiğinde, PublicUser otomatik güncellenir; manuel kopyaladığınız tiplerde ise bu alanı unutmanız olasıdır.

Bu konuları uygulamalı şekilde, farklı seviyelerde örneklerle derinleştirmek isterseniz TypeScript Eğitimi sayfasındaki modüller üzerinden ilerleyebilirsiniz.

Mapped Types: Tipleri dönüştüren şablonlar

Mapped types, bir tipin anahtarlarını dolaşıp yeni bir tip üretmenizi sağlar. Bu, tekrar eden “benzer DTO” tiplerini tek noktada üretmek için harikadır. Ayrıca modern TypeScript sürümlerinde key remapping ile anahtarları dönüştürmek de mümkün hale geldi.

Basit mapped type ile seçenekli alanlar

Örneğin bir form alanının her property’sine validasyon meta bilgisi eklemek isteyebilirsiniz. Domain modeliniz User olsun; siz UserFieldState gibi bir tip üretmek istersiniz. Mapped type ile bunu otomatikleştirirsiniz.

type FieldState<T> = {
  value: T;
  touched: boolean;
  errors: string[];
};

type FormState<T> = {
  [K in keyof T]: FieldState<T[K]>;
};

type UserForm = FormState<{ name: string; email: string; age?: number }>;

Bu şablon, alan sayısı arttıkça daha da değerli olur. Ayrıca keyof temelli olduğundan, model değişiklikleri otomatik yansır. Böylece hem derleyici desteği artar hem de “form state ile domain model uyumsuzluğu” gibi sorunları erken yakalarsınız.

Key remapping ve template literal types ile isim üretmek

Key remapping, anahtarları yeniden adlandırarak yeni tipler üretmeye yarar. Örneğin tüm alanları “getX” fonksiyonlarına dönüştüren bir tip şablonu yazabilirsiniz. Template literal types burada devreye girer. Template literal yazımı backtick ile olduğu için, aşağıdaki örnekte backtick karakterini kaçışlayarak yazıyoruz.

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

type Model = { name: string; age: number };
type ModelGetters = Getters<Model>;
// { getName: () => string; getAge: () => number }

Bu tarz şablonlar, özellikle SDK veya client kütüphanesi geliştirirken API yüzeyini düzenli tutar. Yine de “çok akıllı” tip üretimleri, ekipteki herkesin kolayca anlayabileceği bir düzeyi aşarsa bakım maliyeti artabilir. Bu yüzden bu gücü, tekrarın ciddi olduğu noktalarda kullanmak daha sağlıklıdır.

Tabloda Partial, Pick, Omit ve Record ile oluşturulmuş tip kombinasyonları

Fonksiyon tipleri: Parameters, ReturnType ve overload yerine generics

İleri tipler yalnızca “veri modelleri” için değil, fonksiyon imzaları için de çok işe yarar. Özellikle bir fonksiyonun argümanlarını başka bir fonksiyondan türetmek veya bir callback’in dönüşünü yakalayıp başka bir tipe taşımak istediğinizde, Parameters ve ReturnType gibi araçlar büyük kolaylık sağlar.

Bir fonksiyonun imzasını yeniden kullanmak

Örneğin bir “fetcher” fonksiyonunuz var ve onunla uyumlu bir “cache wrapper” yazmak istiyorsunuz. Wrapper’ın argümanlarını ve dönüşünü tutarlı tutmak için mevcut fonksiyonun imzasından yararlanabilirsiniz.

type FetchUser = (id: string, includeRoles?: boolean) => Promise<{ id: string; name: string; roles?: string[] }>;

type FetchUserArgs = Parameters<FetchUser>; // [string, (boolean | undefined)?]
type FetchUserReturn = ReturnType<FetchUser>; // Promise<...>

function withCache(fn: (...args: any[]) => Promise<any>) {
  const cache = new Map<string, any>();
  return async (...args: Parameters<typeof fn>): ReturnType<typeof fn> => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const res = await fn(...args);
    cache.set(key, res);
    return res;
  };
}

Bu örnekte wrapper “any” kullanıyor gibi görünse de, Parameters/ReturnType sayesinde çağıran tarafta doğru imza korunur. Daha ileri bir adım olarak, withCache fonksiyonunu tam generic yapıp fn tipini parametre olarak alabilirsiniz; böylece her türlü async fonksiyona uyum sağlarsınız.

Pratik stratejiler: İleri tiplerle sürdürülebilirlik

İleri tipler güçlüdür, fakat kontrolsüz kullanıldığında okunabilirliği düşürebilir. Burada amaç, tip oyunları yapmak değil; ürün geliştirmeyi hızlandırmaktır. Aşağıdaki stratejiler, gerçek projelerde bu dengeyi kurmanıza yardım eder.

Tipleri küçük, composable parçalara bölmek

Tek bir dev conditional type yazmak yerine, küçük yardımcı tipler üretin ve onları birleştirin. Örneğin UnwrapPromise, UnwrapArray gibi parçalar, başka yerlerde de tekrar kullanılabilir. Bu yaklaşım, test etmeyi (tip düzeyinde) ve ekip içi paylaşımı kolaylaştırır.

API yüzeyinde sadelik, içeride güç

Kütüphane veya ortak modül geliştirirken, tüketen tarafın görmesi gereken tipleri sade tutun. İleri tipleri içeride kullanıp dışarıya basit bir arayüz sunmak idealdir. Örneğin kullanıcı yalnızca createClient(options) görür; içeride conditional types ile endpoint bazlı dönüşler şekillenir.

Anti-pattern: Her şeyi generic yapmak

Bir fonksiyon yalnızca tek bir tip için yazılmışsa, onu generic hale getirmek bazen gereksizdir. “Şimdilik tek tip” senaryosunda, generics ileride ihtiyaç olduğunda eklenebilir. Aksi halde tip parametreleri, okuyana “burada kaç senaryo var?” sorusunu gereksiz yere sordurur.

Anti-pattern: Tipler üzerinden iş kuralı gizlemek

Tip sistemi iş kuralını “saklamak” için değil, “ifade etmek” için var. Örneğin “admin ise şu alanlar gelir” kuralını yalnızca tip düzeyinde kurup runtime’da bunu doğrulamıyorsanız, sahte bir güvenlik duygusu oluşur. Tipler, runtime doğrulamanın yerine geçmez; onunla tutarlı olmalıdır.


Kapanış: Tipler, daha güvenli refactor demektir

Generics ile tekrar eden kalıpları tekleştirdik, conditional types ile parametreye göre davranan dönüş tiplerini ifade ettik, utility ve mapped types ile model türetmeyi standartlaştırdık. Bunların ortak sonucu: daha az kopyala-yapıştır tip, daha net kontrat ve daha güvenli refactor.

İleri tipleri projeye taşırken küçük adımlarla başlayın: önce en çok tekrar eden DTO’ları Pick/Omit ile toparlayın, sonra servis sonuçlarını generic bir result tipinde standardize edin, en son ihtiyaç duyduğunuz yerlerde conditional types ile dönüşleri keskinleştirin. Böyle ilerlediğinizde, TypeScript’in tip sistemi ekip için “yük” değil, hızlandırıcı olur.

 VERİ AKADEMİ