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

TypeScript'te Template Literal Types: String'leri Tip Seviyesinde Kurmak

Mert Özen 3 Kas 2026 8 dk 1 görüntülenme
TypeScript'te Template Literal Types: String'leri Tip Seviyesinde Kurmak

Template literal type'larla union çarpımını, ${number} gibi kalıpları, Uppercase ve Capitalize intrinsic'lerini, event adı türetmeyi, mapped type ile key üretmeyi ve infer ile route ayrıştırmayı görüyoruz.

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

Önce Tahmin Et

Bir button component'i için CSS class adlarını type olarak tanımlamak istiyoruz:

type Size = "sm" | "lg";
type Color = "red" | "blue";

type ClassName = `btn-${Size}-${Color}`;

İki soru: ClassName union'ında kaç üye var? Ve const className: ClassName = "btn-md-red"; satırını eklersek derlenir mi? Hata verirse derleyici bize ne söyler? Cevaplar bir sonraki iki bölümde.

String'leri Tip Seviyesinde Birleştirmek

Template literal type'lar TypeScript 4.1 ile geldi. Sözdizimi, JavaScript serilerinde kullandığımız template literal'ın birebir aynısı; tek fark değerlerle değil type'larla çalışması. ${…} içine bir değişken değil bir type yazarsın, sonuç da yeni bir string literal type olur:

type Name = "Ada";
type Greeting = `Hello, ${Name}!`; // "Hello, Ada!"

type Flag = `${boolean}`; // "false" | "true"
type Mixed = `${null}-${undefined}-${1n}`; // "null-undefined-1"

${…} içine string, number, bigint, boolean, null ve undefined type'ları konabilir; derleyici her birini JavaScript'te string'e çevrildiğinde aldığı hâliyle yazar. boolean'ın sonucuna dikkat et: yine iki üyeli bir union çıktı. Bunun sebebi bir sonraki bölümün konusu.

Union'larla Çarpım

Template literal içine bir union koyduğunda derleyici her üyeyi ayrı ayrı yerleştirir. Birden fazla union varsa bütün kombinasyonları üretir, yani kartezyen çarpımını alır:

type Size = "sm" | "lg";
type Color = "red" | "blue";
type ClassName = `btn-${Size}-${Color}`;
// "btn-lg-blue" | "btn-lg-red" | "btn-sm-blue" | "btn-sm-red"

// Hata: Type '"btn-md-red"' is not assignable to type '"btn-lg-blue" | "btn-lg-red" | "btn-sm-blue" | "btn-sm-red"'. Did you mean '"btn-sm-red"'?
const className: ClassName = "btn-md-red";

Tahmin sorusunun cevabı: 2 × 2 = 4 üye. Atama ise TS2820 ile reddedilir ve derleyici en yakın adayı da önerir. "md" diye bir boyut tanımlamadığımız için bu class adı hiç var olamaz; bir yazım hatası daha kod çalışmadan yakalanmış olur.

Çarpımın bir bedeli var: üye sayısı çok hızlı büyür. Rakamlarla deneyelim:

type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";

type Pin = `${Digit}${Digit}${Digit}${Digit}`; // 10.000 üye, sorun yok

// Hata: Expression produces a union type that is too complex to represent.
type LongPin = `${Digit}${Digit}${Digit}${Digit}${Digit}`;

Dört rakam 10.000 üye eder ve derleyici bunu kaldırır. Beşinci rakamla 100.000'e ulaşınca TS2590 ile durur. Union'lar küçükken çarpım çok kullanışlıdır; ama "her olası değeri listele" yaklaşımı büyük kümelerde işe yaramaz. Onlar için bir sonraki bölümdeki kalıplar var.

Kalıp Olarak Template Literal

${…} içine string ya da number gibi geniş bir type koyarsan sonuç bir liste değil, bir kalıp olur. Bu kalıba uyan sonsuz sayıda string vardır ve derleyici atamayı kalıba göre denetler:

type Px = `${number}px`;

const width: Px = "12px";
const offset: Px = "-1.5px";

// Hata: Type '"px"' is not assignable to type '`${number}px`'.
const empty: Px = "px";

// Hata: Type '"abcpx"' is not assignable to type '`${number}px`'.
const wrong: Px = "abcpx";

Hata mesajında type'ın açılmadığına, `${number}px` olarak kaldığına dikkat et; listelenecek üye yok. Bu kalıplar CSS değerleri, "user_42" gibi önekli kimlikler ya da `/${string}` ile başlayan path'ler için çok işe yarar.

Intrinsic String Types

TypeScript 4.1 template literal type'larla birlikte dört yardımcı type daha getirdi: Uppercase, Lowercase, Capitalize ve Uncapitalize. Bunlar diğer utility type'lardan farklıdır; lib'deki tanımlarında bir mapped ya da conditional type yoktur:

// lib.es5.d.ts içinden; zaten global oldukları için kendi dosyana kopyalama
type Uppercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;

intrinsic, "bunu derleyici kendisi hesaplar" demektir. Kullanımları ise beklediğin gibi:

type A = Uppercase<"click">; // "CLICK"
type B = Capitalize<"click">; // "Click"
type C = Uncapitalize<"UserId">; // "userId"

type D = Uppercase<"istanbul">; // "ISTANBUL"
type E = Lowercase<"IRMAK">; // "irmak"

Son iki satır Türkçe yazan herkes için önemli. Bu dönüşümler dile bağlı değildir; JavaScript'teki toUpperCase() gibi çalışırlar, toLocaleUpperCase("tr-TR") gibi değil. Bu yüzden "istanbul", "İSTANBUL" değil "ISTANBUL" olur. Key ve event adları gibi teknik string'ler için doğru davranış budur; kullanıcıya gösterilecek metinleri zaten tip seviyesinde dönüştürmezsin.

Event Adı Türetmek

Template literal type'ların en yaygın kullanımı, var olan adlardan yeni adlar türetmek. Bir event listesinden handler adları üretelim:

type DomEvent = "click" | "keydown" | "scroll";

type HandlerName = `on${Capitalize<DomEvent>}`; // "onClick" | "onKeydown" | "onScroll"

Daha gerçekçi bir örnek: değişikliklerini dinleyebildiğin küçük bir store. Her property için nameChanged, ageChanged gibi event adları otomatik oluşsun ve callback doğru type'ı alsın:

interface Profile {
  name: string;
  age: number;
}

function createStore<T extends object>(state: T) {
  const listeners: Partial<Record<string, (value: unknown) => void>> = {};
  return {
    on<K extends keyof T & string>(event: `${K}Changed`, callback: (value: T[K]) => void) {
      listeners[event] = callback as (value: unknown) => void;
    },
    set<K extends keyof T & string>(key: K, value: T[K]) {
      state[key] = value;
      listeners[`${key}Changed`]?.(value);
    },
  };
}

const store = createStore<Profile>({ name: "Ada", age: 36 });
store.on("ageChanged", (age) => console.log(`age is now ${age.toFixed(0)}`));
store.set("age", 37); // age is now 37

// Hata: Argument of type '"ageChange"' is not assignable to parameter of type '"ageChanged" | "nameChanged"'.
store.on("ageChange", () => {});

Burada üç şey birlikte çalışıyor. `${K}Changed` parametresi, derleyicinin "ageChanged" string'inden geriye doğru K = "age" çıkarmasını sağlıyor. Bu sayede callback'teki age parametresi number oluyor ve toFixed serbestçe kullanılabiliyor. Yanlış yazılmış event adı ise TS2345 ile, geçerli bütün adlar listelenerek reddediliyor. Store'un içindeki as, listener'ları tek bir map'te tutabilmek için; dışarıya açılan API tamamen tip güvenli.

Mapped Type ile Birlikte

Bir önceki yazıda as ile key'leri yeniden adlandırabildiğimizi görmüştük. Template literal type'lar o as'ın en doğal ortağı:

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

interface Profile {
  name: string;
  age: number;
}

type Getters<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K] };
type ProfileGetters = Expand<Getters<Profile>>;
// { getAge: () => number; getName: () => string; }

interface AppEvents {
  login: { userId: number };
  logout: undefined;
}

type Handlers<E> = { [K in keyof E as `on${Capitalize<K & string>}`]: (payload: E[K]) => void };
type AppHandlers = Expand<Handlers<AppEvents>>;
// { onLogin: (payload: { userId: number; }) => void; onLogout: (payload: undefined) => void; }

Tek bir event map'inden hem handler adlarını hem de her handler'ın payload type'ını türettik. AppEvents'e yeni bir event eklediğinde AppHandlers kendiliğinden güncellenir. K & string'in neden orada olduğunu tuzaklar bölümünde göreceğiz.

infer ile String Ayrıştırmak

Template literal'lar infer ile birleşince string'leri tip seviyesinde parçalayabilirsin. Bir route path'inden parametre adlarını çıkaralım:

type RouteParams<Path> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | RouteParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P1 = RouteParams<"/users/:userId/posts/:postId">; // "postId" | "userId"
type P2 = RouteParams<"/about">; // never

function buildPath<P extends string>(path: P, params: Record<RouteParams<P>, string>): string {
  return path.replace(/:(\w+)/g, (_, name: string) => params[name as RouteParams<P>]);
}

console.log(buildPath("/users/:userId/posts/:postId", { userId: "42", postId: "7" })); // /users/42/posts/7

// Hata: Property 'userId' is missing in type '{}' but required in type 'Record<"userId", string>'.
buildPath("/users/:userId", {});

İlk dal, iki nokta üst üsteden sonraki adı bir sonraki /'a kadar yakalar ve kalan path için RouteParams'ı tekrar çağırır. İkinci dal, path'in sonundaki son parametreyi yakalar. Hiç parametre yoksa never kalır. buildPath artık path'teki her parametrenin verilmesini zorunlu kılıyor; eksik olan TS2741 ile yakalanıyor.

Sık Düşülen Tuzaklar

İlki keyof'un yalnızca string döndürmemesi. Bir object type'ın key'leri string | number | symbol olabilir ve symbol bir template literal'ın içine konamaz. K & string yazmadan `get${K}` denersen derleyici şöyle itiraz eder: "Type 'K' is not assignable to type 'string | number | bigint | boolean | null | undefined'." (TS2322). K & string ya da keyof T & string yalnızca string key'leri bırakır.

İkincisi `${number}`'ı bir doğrulama aracı sanmak. Derleyici, JavaScript'in sayıya çevirebildiği string'leri kabul eder; "1e3px", "0x10px", hatta " 12px" gibi baştaki boşluklu değerler de bu kalıba uyar. Kalıp yalnızca derleme zamanında yazdığın literal'ları denetler; kullanıcıdan gelen veriyi çalışma zamanında yine sen doğrulamalısın.

Üçüncüsü, çarpımın patlaması. Birkaç union'ı tek bir template'te birleştirmek kolay, ama her yeni ${…} üye sayısını çarpar. Editör yavaşlıyorsa ya da TS2590 görüyorsan, listeyi kalıba (`${string}`, `${number}`) çevirmeyi düşün.

Son olarak, TypeScript 7 ile template literal inference Unicode karakterleri bütün olarak korur. "😀abc" üzerinde `${infer First}${string}` kalıbı TypeScript 7.0.2'de "😀" verir. Playground hâlâ JavaScript ile yazılmış 6.x derleyicisini çalıştırdığı için bu tür uç durumlarda farklı sonuç görebilirsin.

Küçük Bir Deneme

★☆☆ Tahmin et: type Axis = "x" | "y"; için `margin-${Axis}` | `padding-${Axis}` kaç üyeli? Sonra null hilesiyle doğrula.

★★☆ Getters'ın benzeri bir Setters<T> yaz: Profile için sonuç { setAge: (value: number) => void; setName: (value: string) => void; } olmalı.

★★★ KebabToCamel<S> yaz: "background-color" için "backgroundColor" versin. İpucu: `${infer Head}-${infer Tail}` kalıbı, Capitalize ve RouteParams'taki gibi kendini çağırmak.

RouteParams ve KebabToCamel kendilerini çağırıyor; string bitene kadar her adımda bir parça işleyip kalanı yine kendilerine veriyorlar. Bir sonraki yazıda bu fikri merkeze alacağız: recursive tipler, kendine referans veren bir JSON type'ı, DeepReadonly ve DeepPartial, ve derleyicinin bu recursion'a nerede "yeter" dediği.