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

TypeScript'te Recursive Tipler: JSON, DeepReadonly ve DeepPartial

Mert Özen 3 Kas 2026 8 dk 1 görüntülenme
TypeScript'te Recursive Tipler: JSON, DeepReadonly ve DeepPartial

Kendine referans veren type'ları, recursive bir JSON type'ını, fonksiyon ve array tuzaklarıyla DeepReadonly ve DeepPartial'ı, recursive conditional type'ları ve TS2589 sınırını görüyoruz.

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

Önce Tahmin Et

JSON'a çevrilebilecek her değeri tarif eden bir type yazdık. Dikkat et, type kendi tanımının içinde kendini kullanıyor:

type Json = string | number | boolean | null | Json[] | { [key: string]: Json };

const profile: Json = { name: "Ada", tags: ["admin"], meta: { active: true } };
const entry: Json = { createdAt: new Date() };
const options: Json = { retry: undefined };

Üç atamadan hangileri derlenir? Hata verenler varsa, derleyici neye itiraz eder? JSON.stringify'ın Date ve undefined ile ne yaptığını JavaScript serilerinden hatırlıyorsan ipucu orada.

Kendine Referans Veren Tipler

Recursive bir type, tanımının içinde kendisine referans veren type'tır. En tanıdık örneği ağaç yapıları: bir klasörün alt klasörleri, bir yorumun cevapları, bir menünün alt menüleri. Interface'ler bunu en baştan beri yapabiliyordu:

interface TreeNode {
  value: number;
  children: TreeNode[];
}

function sum(node: TreeNode): number {
  return node.children.reduce((total, child) => total + sum(child), node.value);
}

const tree: TreeNode = {
  value: 1,
  children: [
    { value: 2, children: [] },
    { value: 3, children: [{ value: 4, children: [] }] },
  ],
};

console.log(sum(tree)); // 10

Type'ın recursive olması, onu işleyen fonksiyonun da genellikle recursive olması demek. sum, her düğümde kendi değerini çocuklarının toplamına ekliyor; çocuğu olmayan düğüm doğal bir bitiş noktası.

Type alias'lar ise TypeScript 3.7'ye kadar bu konuda kısıtlıydı. O sürümden beri bir alias, kendine bir yapının arkasından (object property, array, tuple ya da conditional type dalı) referans verebiliyor. Arada hiçbir yapı yoksa derleyici sonsuz döngüyü hemen fark eder:

type Nested = string | Nested[]; // sorun yok: referans array'in arkasında

// Hata: Type alias 'Loop' circularly references itself.
type Loop = Loop | string;

Nested'ı açmak için önce bir array görmen gerekir, o yüzden derleyici onu ihtiyaç duydukça bir seviye daha açar. Loop'u açmaya çalışmak ise hemen yine Loop'a çıkar ve TS2456 verir.

JSON Tipi

Tahmin sorusuna dönelim. profile sorunsuz derlenir: string'ler, string array'i ve iç içe bir object var; hepsi Json'ın bir üyesine uyuyor. Diğer ikisi hata verir. entry için derleyicinin açıklaması şöyle başlar:

error TS2322: Type '{ createdAt: Date; }' is not assignable to type 'Json'.
  Types of property 'createdAt' are incompatible.
    Type 'Date' is not assignable to type 'Json | undefined'.
      Type 'Date' is not assignable to type '{ [key: string]: Json; }'.
        Index signature for type 'string' is missing in type 'Date'.

Date bir object, ama Json'daki object üyesi her property'si yine Json olan bir index signature istiyor ve Date bu şekle uymuyor. Bu, gerçek hayatla da örtüşüyor: JSON.stringify bir tarihi string'e çevirir, geri okuduğunda elinde Date değil string vardır. options ise TS2322 ile reddedilir, çünkü undefined JSON'da yoktur; JSON.stringify böyle bir property'yi sessizce atar.

Recursive bir type'ı işleyen fonksiyon da type'ın şeklini izler. Bir JSON değerindeki bütün yaprakları (iç içe olmayan değerleri) sayalım:

type Json = string | number | boolean | null | Json[] | { [key: string]: Json };

function countLeaves(value: Json): number {
  if (value === null || typeof value !== "object") {
    return 1;
  }
  const children = Array.isArray(value) ? value : Object.values(value);
  let total = 0;
  for (const child of children) {
    total += countLeaves(child);
  }
  return total;
}

const config: Json = JSON.parse('{"name":"Ada","tags":["admin","dev"],"meta":{"active":true}}');
console.log(countLeaves(config)); // 4

Bir uyarı: JSON.parse any döndürür, yani Json'a atama hiçbir şeyi denetlemez. Orta seride konuştuğumuz gibi, dışarıdan gelen veriyi çalışma zamanında doğrulamak hâlâ senin işin; Json type'ı yalnızca doğrulamadan sonra elindekinin şeklini tarif eder.

DeepReadonly

Mapped type yazısında Readonly'nin sığ olduğunu, iç içe object'lere inmediğini görmüştük. Recursive bir mapped type ile her seviyeyi dondurabiliriz. İlk deneme şöyle:

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

declare const handler: DeepReadonlyNaive<() => void>;

// Hata: This expression is not callable.
handler();

String ve number gibi primitive'lerde sorun yok; homomorphic bir mapped type primitive'e uygulanınca primitive'i olduğu gibi bırakır. Array'lerde de doğru çalışır ve string[]'i readonly string[] yapar. Ama fonksiyonlar bozulur: mapped type bir fonksiyon type'ının property'lerini gezer, çağrı imzasını taşımaz ve geriye çağrılamayan boş bir object kalır (TS2349). Çözüm, fonksiyonları conditional type ile ayırmak:

type DeepReadonly<T> =
  T extends (...args: any[]) => unknown ? T :
  T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } :
  T;

interface Settings {
  theme: { color: string; fonts: string[] };
  onChange: (value: string) => void;
}

const settings: DeepReadonly<Settings> = {
  theme: { color: "dark", fonts: ["Inter"] },
  onChange: (value) => console.log(value),
};

// Hata: Cannot assign to 'color' because it is a read-only property.
settings.theme.color = "light";

// Hata: Property 'push' does not exist on type 'readonly string[]'.
settings.theme.fonts.push("Arial");

settings.onChange("ok"); // ok

Üç dal var: fonksiyonsa olduğu gibi bırak, object'se (array dahil) her property'yi readonly yap ve aynı işlemi bir seviye aşağıda tekrarla, primitive'se dokunma. Primitive'ler doğal bitiş noktası; recursion orada durur.

DeepPartial

Partial'ın da derin bir versiyonuna sık ihtiyaç duyulur: bir ayar object'inin yalnızca değişen kısmını göndermek gibi. Burada array'leri de ayrıca ele alıyoruz, çünkü "fonts listesinin bir kısmı" pek anlamlı değil; array ya hep gelir ya hiç:

type DeepPartial<T> =
  T extends (...args: any[]) => unknown ? T :
  T extends readonly unknown[] ? T :
  T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } :
  T;

interface Settings {
  theme: { color: string; fonts: string[] };
  onChange: (value: string) => void;
}

const current: Settings = {
  theme: { color: "dark", fonts: ["Inter"] },
  onChange: (value) => console.log(value),
};

function applyPatch(patch: DeepPartial<Settings>): Settings {
  return { ...current, ...patch, theme: { ...current.theme, ...patch.theme } };
}

const next = applyPatch({ theme: { color: "light" } });
console.log(next.theme.color, next.theme.fonts); // light [ 'Inter' ]

patch artık { theme: { color: "light" } } kadar küçük olabiliyor; fonts ve onChange vermek zorunda değiliz. Array ve fonksiyon dallarını yazmasaydık ne olurdu? Naive bir DeepPartial, string[]'i (string | undefined)[]'e, onChange'i ise boş object type'ı {}'e çeviriyor; ikisi de istediğimiz şey değil.

Tip Seviyesinde Döngü: Recursive Conditional Types

Şimdiye kadarki örneklerde recursion bir veri yapısını izliyordu. Conditional type'lar TypeScript 4.1'den beri kendilerini dallarında doğrudan çağırabiliyor; bu da tip seviyesinde döngü kurmak demek. Bir önceki yazıdaki RouteParams buna örnekti. Daha sade bir örnek, bir string'i belli sayıda tekrar eden bir type:

type Repeat<S extends string, N extends number, Count extends unknown[] = []> =
  Count["length"] extends N ? "" : `${S}${Repeat<S, N, [...Count, unknown]>}`;

type Three = Repeat<"ab", 3>; // "ababab"

Tip seviyesinde sayaç yok, o yüzden tuple'ın length'ini sayaç olarak kullanıyoruz: her adımda Count'a bir eleman ekliyor, uzunluk N'ye eşitlenince duruyoruz. Bitiş koşulu, yani base case, her recursive type'ın olmazsa olmazı.

Recursion Sınırları ve Tail Recursion

Derleyici sonsuz döngüye girmemek için iç içe açılımları sınırlar. Repeat ile denersen sınırı somut olarak görürsün:

type Repeat<S extends string, N extends number, Count extends unknown[] = []> =
  Count["length"] extends N ? "" : `${S}${Repeat<S, N, [...Count, unknown]>}`;

type R47 = Repeat<"a", 47>; // sorun yok

// Hata: Type instantiation is excessively deep and possibly infinite.
type R48 = Repeat<"a", 48>;

TypeScript 7.0.2'de bu type 47 tekrarda çalışıyor, 48'de TS2589 veriyor. Kesin sayı type'ın şekline göre değişir; önemli olan, sınırın düşünüldüğünden çok daha yakın olması. Sebep şu: her adımın sonucu bir template literal'ın içinde kullanılıyor, yani derleyici iç çağrının sonucunu beklemek zorunda ve açılımlar üst üste birikiyor.

TypeScript 4.5 bunun için bir çözüm getirdi: bir conditional type'ın dalı doğrudan yine bir conditional type çağrısıysa (tail position), derleyici ara açılımları biriktirmeden döngüyü sürdürür. Bunun için sonucu bir accumulator parametresinde taşırız:

type RepeatFast<S extends string, N extends number, Count extends unknown[] = [], Acc extends string = ""> =
  Count["length"] extends N ? Acc : RepeatFast<S, N, [...Count, unknown], `${Acc}${S}`>;

type F999 = RepeatFast<"a", 999>; // sorun yok

// Hata: Type instantiation is excessively deep and possibly infinite.
type F1000 = RepeatFast<"a", 1000>;

Aynı işi yapan type artık 999 tekrara kadar çalışıyor, 1000'de duruyor. Fark tek bir şeyde: kendini çağırma sonucu başka bir type'ın içine konmuyor, dalın kendisi oluyor. JavaScript'te bir değeri accumulator parametresiyle taşıyıp en sonda döndürmekle aynı fikir.

Sık Düşülen Tuzaklar

İlki base case'i unutmak ya da hiç ulaşılamayan bir base case yazmak. Repeat<"a", -1> gibi bir çağrıda uzunluk hiçbir zaman -1 olmayacağı için recursion sınıra kadar sürer ve yine TS2589 alırsın. Hata mesajı "possibly infinite" diyorsa önce bitiş koşuluna bak.

İkincisi, DeepReadonly'nin her object'i aynı gördüğünü unutmak. Map ve Set gibi class'ların değiştiren method'ları birer fonksiyondur ve bizim type'ımız fonksiyonlara dokunmaz. Yani DeepReadonly<Map<string, number>> üzerinde set hâlâ çağrılabilir. Böyle type'lar için ayrı bir dal gerekir.

Üçüncüsü performans. Derin recursive type'lar, özellikle büyük union'larla birleşince, editörü ve tip denetimini fark edilir şekilde yavaşlatabilir. Tip seviyesinde yazabiliyor olman, her şeyi tip seviyesinde yazman gerektiği anlamına gelmez; okunabilirliği ve denetim süresini de hesaba kat.

Küçük Bir Deneme

★☆☆ Derlenir mi, tahmin et: type Nested = number | Nested[]; için const a: Nested = [1, [2, [3, [4]]]]; ve const b: Nested = [1, ["2"]];. Hangisi hata verir?

★★☆ DeepReadonly'ye Map için bir dal ekle: T extends Map<infer K, infer V> ise ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> döndür. Sonra set çağrısının hata verdiğini gör.

★★★ Bir string'i ters çeviren tail-recursive bir Reverse<S> yaz: Reverse<"abc"> sonucu "cba" olmalı. İpucu: `${infer First}${infer Rest}` kalıbı ve bir Acc parametresi.

Son bir soru bir sonraki yazıya kapı açıyor: DeepReadonly<Settings> type'ındaki bir değeri Settings bekleyen bir yere verebilir misin? Dene: derleyici theme.fonts için itiraz ediyor, çünkü readonly bir array'i değiştirilebilir bir array'e atayamazsın; ters yönde ise sorun yok. Atanabilirliğin bir yönü var. Bir sonraki yazıda bu yönü sistemli olarak konuşacağız: covariance, contravariance, strictFunctionTypes ve in/out variance annotation'ları.