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

TypeScript'te Mapped Types: Bir Type'tan Yeni Bir Type Üretmek

Mert Özen 3 Kas 2026 8 dk 2 görüntülenme
TypeScript'te Mapped Types: Bir Type'tan Yeni Bir Type Üretmek

Mapped type sözdizimini, homomorphic mapped type'ların readonly ve ? modifier'larını nasıl taşıdığını, + ve - ile modifier yönetimini, as ile key remapping ve never ile filtrelemeyi görüyoruz.

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

Önce Tahmin Et

Bir config type'ımız var; bütün property'leri readonly, biri de optional. Readonly'yi kaldıran bir type yazıp kullanıyoruz:

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

interface Config {
  readonly host: string;
  readonly port?: number;
}

const config: Mutable<Config> = { host: "localhost" };
config.host = "example.com";

Bu kod derlenir mi? Hata verirse hangi satırda? İki ayrı soru saklı: port vermeden object oluşturabiliyor muyuz, yani port hâlâ optional mı? Ve host'a atama yapabiliyor muyuz? Cevap, modifier'ların mapped type'larda nasıl taşındığını gördükten sonra.

Type'lar Üzerinde for...in

Mapped type, bir union'daki her key için bir property üreten yapıdır. JavaScript'teki for...in döngüsünü düşün: bir object'in key'lerini tek tek gezer. Mapped type aynı gezintiyi tip seviyesinde yapar:

type Flags = { [K in "darkMode" | "beta"]: boolean };

const flags: Flags = { darkMode: true, beta: false };

Köşeli parantezin içi döngünün başlığı gibi okunur: ""darkMode" | "beta" union'ındaki her K için, K adında, type'ı boolean olan bir property oluştur." Asıl güç, gezilen union'ı başka bir type'tan aldığında ortaya çıkar. Orta seride gördüğümüz keyof ve indexed access burada buluşur:

type Nullable<T> = { [K in keyof T]: T[K] | null };

type Draft = Nullable<{ name: string; age?: number }>;
// { name: string | null; age?: number | null | undefined; }

keyof T bütün key'leri verir, T[K] o key'in mevcut type'ını. Sağ tarafta istediğin dönüşümü yaparsın. Orta seride kullandığımız Partial, Readonly, Pick ve Record'un hepsi lib'de tam olarak böyle tanımlı birer mapped type.

Sonucu Görmek: Expand Yardımcısı

Serinin ilk yazısında, bir type'ı görmek için ona null atayıp hata mesajını okumuştuk. Object üreten mapped type'larda bu hile tek başına yetmez; derleyici açılımı değil alias adını yazar:

error TS2322: Type 'null' is not assignable to type 'Mutable<Config>'.

Topluluğun çok kullandığı küçük bir yardımcı type bu sorunu çözer. Kendisi de bir mapped type'tır; boş object type'la ({}) intersection'a girmesi, derleyiciyi alias adı yerine property listesini yazmaya zorlar:

type Expand<T> = { [K in keyof T]: T[K] } & {};
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

interface Config {
  readonly host: string;
  readonly port?: number;
}

// Hata: Type 'null' is not assignable to type '{ host: string; port?: number | undefined; }'.
const reveal: Expand<Mutable<Config>> = null;

Tahmin sorusunun cevabı mesajda: readonly gitmiş, ? yerinde duruyor. Yani kod hatasız derlenir; port vermeden object oluşturabilir, host'u değiştirebilirsin. Editörde de type X = Mutable<Config> yazıp X'in üzerine gelirsen aynı açılımı görürsün. Bu yazıdaki sonuç yorumlarını bu yolla TypeScript 7.0.2'de denetledik.

Homomorphic Mapped Types: Modifier'lar Taşınır

Mutable'da -readonly yazdık ama ? hakkında hiçbir şey söylemedik. Buna rağmen port optional kaldı. Sebep şu: [K in keyof T] biçimindeki bir mapped type, homomorphic (yapıyı koruyan) mapped type sayılır ve T'deki her property'nin readonly ve ? işaretlerini kendiliğinden taşır. Aynı key'leri elle yazınca bu bağ kopar:

interface Config {
  readonly host: string;
  readonly port?: number;
}

type Copy<T> = { [K in keyof T]: T[K] };
type Copied = Copy<Config>; // { readonly host: string; readonly port?: number | undefined; }

type ByHand = { [K in "host" | "port"]: Config[K] }; // { host: string; port: number | undefined; }

type HostOnly = Pick<Config, "host">; // { readonly host: string; }

ByHand'de key'ler aynı olsa da derleyici onların Config'ten geldiğini bilmez; readonly da ? da kaybolur, port artık zorunlu ama undefined alabilen bir property olur. Pick ise K extends keyof T constraint'i sayesinde bağı korur. Kural kısaca: key'leri keyof T'den (ya da keyof T'ye kısıtlı bir parametreden) alırsan modifier'lar gelir.

readonly ve ?: + ve - ile Modifier Yönetimi

TypeScript 2.8'den beri modifier'ların önüne + ya da - koyabiliyoruz. + ekler (yazmasan da varsayılan budur), - kaldırır:

type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };
type Frozen<T> = { +readonly [K in keyof T]+?: T[K] };

type C1 = Concrete<{ a?: string | undefined; b?: number }>; // { a: string; b: number; }
type C2 = Frozen<{ a: string }>; // { readonly a?: string | undefined; }

Concrete, lib'deki Required'ın aynısı. Burada bir incelik var: -? sadece soru işaretini kaldırmakla kalmaz, property type'ındaki undefined'ı da temizler. a?: string | undefined açıkça undefined içerse bile sonuç a: string olur. "Bu alan artık kesin dolu" demenin tip seviyesindeki karşılığı bu.

as ile Key Remapping

TypeScript 4.1 ile mapped type'a bir as bölümü eklenebiliyor. as'tan sonra yazdığın type, property'nin yeni adı olur:

interface User {
  id: number;
  name: string;
  password: string;
  save(): void;
}

type PublicUser = { [K in keyof User as Exclude<K, "password">]: User[K] };
// { id: number; name: string; save: () => void; }

Key'i Exclude ile süzdük, ama değer tarafında hâlâ orijinal K ile User[K]'yı okuyabiliyoruz. Omit da benzer bir işi yapar; as'ın farkı, adı istediğin gibi dönüştürebilmen. Key'leri getName, onClick gibi yeni adlara çevirmeyi template literal type'larla birlikte bir sonraki yazıda yapacağız.

as bir kapı daha açar: artık yalnızca key'ler üzerinde değil, herhangi bir union üzerinde gezebilirsin. Discriminated union'ın her üyesi için ayrı bir handler tanımlamak buna güzel bir örnek:

type Shape = { kind: "circle"; radius: number } | { kind: "square"; size: number };

type ShapeHandlers = { [S in Shape as S["kind"]]: (shape: S) => number };

const area: ShapeHandlers = {
  circle: (shape) => Math.PI * shape.radius ** 2,
  square: (shape) => shape.size ** 2,
};

console.log(area.square({ kind: "square", size: 3 })); // 9

S burada bir key değil, union'ın bir üyesi, yani bir object type. Key olarak onun kind'ını kullanıyoruz, değerde ise S'in kendisini. Sonuçta circle handler'ı yalnızca daireyi, square handler'ı yalnızca kareyi kabul eder; shape.radius için hiçbir narrowing yazmadık.

Filtreleme: as never

Önceki iki yazıda never'ın union'dan iz bırakmadan düştüğünü gördük. Mapped type'ta da as bir key için never üretirse o property hiç oluşmaz. Böylece key'e göre değil, değere göre süzebilirsin:

interface Product {
  id: number;
  title: string;
  note?: string;
  price: number;
}

type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };

type NumberFields = PickByValue<Product, number>; // { id: number; price: number; }

Her property için conditional type çalışır: değeri number'a atanabiliyorsa key kalır, değilse never olup düşer. note'un string | undefined type'ı da number'a atanamadığı için elenir. Function property'leri atıp yalnızca veri alanlarını tutmak için de aynı kalıp işe yarar: T[K] extends Function ? never : K.

Array ve Tuple Üzerinde Mapped Types

Homomorphic mapped type'lar array ve tuple'larda özel davranır: sonuç yine array ya da tuple olur, uzunluk korunur:

type Stringify<T> = { [K in keyof T]: string };

type S1 = Stringify<[number, boolean]>; // [string, string]
type S2 = Stringify<number[]>; // string[]

const pair: Stringify<[number, boolean]> = ["1", "true"];

Derleyici burada length ya da push gibi array üyelerini dönüştürmez; yalnızca elemanlara dokunur. Bu davranış as eklediğin anda biter. { [K in keyof T as K]: T[K] } hiçbir şeyi değiştirmiyor gibi görünse de tuple'a uygulandığında sonuç artık tuple değil, length, push ve diğer bütün array method'larını property olarak taşıyan sıradan bir object type'tır.

Sık Düşülen Tuzaklar

as gelmeden önce yaygın olan bir kalıp hâlâ çok kodda yaşıyor: değeri belli bir type olan key'leri union olarak toplamak. Optional property'lerde sinsi bir açığı var:

interface Product {
  id: number;
  title: string;
  note?: string;
  price: number;
}

type KeysOfType<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T];

const key: KeysOfType<Product, string> = undefined; // derlenir!

Homomorphic mapped type note'un ?'ini taşıdığı için o property'nin type'ı never | undefined olur ve en sondaki [keyof T] erişimi union'a bir undefined ekler. Çözüm, ara type'ta -? kullanmak: { [K in keyof T]-?: … }[keyof T]. O zaman sonuç tam olarak "title" olur ve undefined atamak TS2322 verir.

İkincisi, modifier'ların sessizce kaybolması. Key'leri keyof T yerine elle yazılmış bir union'dan alırsan readonly ve ? taşınmaz. "Neden bu alan artık zorunlu?" diye şaşırırsan önce mapped type'ın homomorphic olup olmadığına bak.

Üçüncüsü, readonly'nin sığ olması:

interface Settings {
  readonly theme: { color: string };
}

const settings: Readonly<Settings> = { theme: { color: "dark" } };
settings.theme.color = "light"; // derlenir: iç object readonly değil

Mapped type yalnızca bir seviye iner. İç içe object'leri de dondurmak için mapped type'ın kendini çağırması gerekir; bu DeepReadonly'yi recursive tipler yazısında yazacağız.

Küçük Bir Deneme

★☆☆ Tahmin et, sonra Expand ile doğrula: type Flags<T> = { [K in keyof T]: boolean }; için Flags<{ readonly a?: string }> hangi type? readonly ve ? kalır mı?

★★☆ PickByValue'nun tersini, OmitByValue<T, V>'yi yaz. OmitByValue<Product, number> sonucu { title: string; note?: string | undefined; } olmalı.

★★★ Bu yazının Mutable'ını readonly [1, 2] tuple'ına uygula. Sonuç tuple olarak mı kalır, object'e mi döner? Neden?

Mapped type'larla key'leri gezdik, süzdük ve modifier'larını değiştirdik. Bir sonraki yazıda key'lerin adlarını da dönüştüreceğiz: template literal type'lar, Uppercase ve Capitalize gibi intrinsic'ler ve name'den getName'i, click'ten onClick'i türeten mapped type'lar.