TypeScript'te Union ve Literal Tipler: Bir Değer, Birkaç İhtimal
Union ile 'ya bu ya şu' tiplerini, literal tiplerle sınırlı değer listelerini, object içinde literal'lerin neden genişlediğini, as const'a ilk bakışı ve union'ların neden daraltma gerektirdiğini görüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Önce Tahmin Et
Bir önceki yazıda type alias'ın union'ları da ifade edebildiğini söylemiştik. Bu yazı tam olarak onlarla ilgili. Önce şu koda bak ve tahmin et: kaç satır hata verir, hangileri?
type Status = "idle" | "loading" | "done";
let current: Status = "idle";
current = "loading";
current = "finished";
function formatId(id: string | number) {
return id.toUpperCase();
}
İpucu: Status'un sağındaki değerler bildiğin string'ler gibi görünüyor ama burada bir tipin parçası olarak duruyorlar. Cevabı iki parça hâlinde, ilgili bölümlerde vereceğiz.
Union: "Ya Bu Ya Şu"
Gerçek hayattaki değerler her zaman tek bir tipe sığmaz. Bir kimlik numarası bazen sayı, bazen string olarak gelir; bir arama sonucu ya veri ya hata taşır. TypeScript bu durumları union ile tarif eder:
function printId(id: string | number) {
console.log(`ID: ${id}`);
}
printId(101);
printId("a-101");
// Hata: Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
printId(true);
Dikey çizgiyi "ya da" diye oku: string | number, "string ya da number" demektir. Union'ın her parçasına member denir. Bu fonksiyon iki tipten birini kabul eder; üçüncü bir tip gelirse derleyici itiraz eder.
Union'ı aslında daha önce görmüştük: strictNullChecks açıkken "bu değer yoksa null olabilir" demenin yolu string | null yazmaktı. Bir önceki yazıda gördüğümüz type alias ile union'lara isim de verebilirsin, hatta member'lar object tipi olabilir:
type Id = string | number;
type ApiResult =
| { data: string[] }
| { error: string };
Uzun union'ları birden fazla satıra bölerken ilk satırın başına da bir | koymak yaygın bir alışkanlıktır; TypeScript buna izin verir ve her member alt alta düzgünce hizalanır.
Union'la Ne Yapabilirsin?
Baştaki sorunun ilk cevabı burada. formatId içindeki id.toUpperCase() satırı hata verir:
error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
Property 'toUpperCase' does not exist on type 'number'.
Mesajın ikinci satırı önemli: sorun number tarafında. id string olabilir, ama number da olabilir ve number'ın toUpperCase diye bir method'u yok. TypeScript, union bir değer üzerinde yalnızca union'ın her member'ı için geçerli olan işlemlere izin verir:
function describeId(id: string | number) {
return id.toString(); // hem string'de hem number'da var
}
Bunu şöyle düşün: kapıdan ya bir kedi ya bir köpek girecek ve hangisi olduğunu bilmiyorsun. Ona "havla" diyemezsin, çünkü kedi olabilir. Ama ikisinin de yapabildiği bir şeyi, mesela yürümesini, güvenle bekleyebilirsin. Union, değerin alabileceği ihtimalleri genişletir; buna karşılık değerle yapabileceğin işlemleri bütün ihtimallerin ortak noktasına daraltır. Bu ikisini birlikte aklında tutman, union'larla çalışmanın anahtarı.
Literal Tipler: Değerin Kendisi Bir Tip
Inference'ı konuştuğumuz yazıda let ve const arasındaki farka kısaca bakmıştık:
let mode = "dark"; // type'ı: string
const theme = "dark"; // type'ı: "dark"
const ile tanımlanan değişken bir daha değişemeyeceği için TypeScript onun tipini "dark" olarak çıkarır: yalnızca ve yalnızca bu string. Buna literal type denir. String'lerin yanında sayıların ve boolean'ların da literal tipleri vardır: 42, true.
Tek başına bir literal type pek işe yaramaz; "bu değişken sadece "dark" olabilir" demek çoğu zaman anlamsız. Asıl güç, literal'leri union ile birleştirdiğinde ortaya çıkar:
type Theme = "light" | "dark" | "system";
type DiceRoll = 1 | 2 | 3 | 4 | 5 | 6;
function setTheme(theme: Theme) {
console.log(`Theme set to ${theme}`);
}
setTheme("dark");
// Hata: Argument of type '"blue"' is not assignable to parameter of type 'Theme'.
setTheme("blue");
Artık setTheme'e istediğin string'i veremezsin; yalnızca listedeki üç değerden biri geçer. Yazım hatası yapma ihtimalin de ortadan kalkar: editör setTheme(" yazdığın anda üç seçeneği önerir. Boolean da aslında böyle bir union'dır; TypeScript boolean'ı true | false olarak tutar.
Baştaki sorunun ikinci cevabı da burada: current = "finished" satırı hata verir, çünkü "finished", Status'un member'larından biri değil. TypeScript'in mesajı Type '"finished"' is not assignable to type 'Status'. şeklindedir. Yani o kodda iki hata vardı: biri literal union'dan, biri de union üzerindeki geçersiz bir method çağrısından.
Literal'ler Object İçinde Neden Genişler?
Literal tiplerle çalışırken herkesin bir kez çarptığı bir duvar var. Şu koda bak:
type Method = "GET" | "POST";
function request(url: string, method: Method) {
console.log(`${method} ${url}`);
}
const options = { url: "/api/users", method: "GET" };
// Hata: Argument of type 'string' is not assignable to parameter of type 'Method'.
request(options.url, options.method);
"GET", Method'un member'larından biri; peki neden hata var? Çünkü options bir const olsa da içindeki property'ler değiştirilebilir. Yarın biri options.method = "DELETE" yazabilir. TypeScript bu ihtimali hesaba katar ve property'nin tipini "GET" yerine string olarak çıkarır. Buna widening denir: literal type, daha geniş olan temel tipe genişler. string ise Method'a atanamaz.
Çözümün ilki, niyetini tiple açıkça söylemektir:
type Method = "GET" | "POST";
type RequestOptions = {
url: string;
method: Method;
};
function request(url: string, method: Method) {
console.log(`${method} ${url}`);
}
const options: RequestOptions = { url: "/api/users", method: "GET" };
request(options.url, options.method);
Bu genellikle en iyi yoldur: options'ın neye benzemesi gerektiği tek bir yerde tanımlanır ve yanlış bir method yazdığında hatayı tanımın olduğu satırda görürsün. Ama her küçük object için ayrı bir tip yazmak istemeyebilirsin. O zaman ikinci yol devreye girer.
as const'a İlk Bakış
type Method = "GET" | "POST";
function request(url: string, method: Method) {
console.log(`${method} ${url}`);
}
const options = {
url: "/api/users",
method: "GET",
} as const;
// options: { readonly url: "/api/users"; readonly method: "GET" }
request(options.url, options.method); // artık hata yok
const methods = ["GET", "POST"] as const;
// methods: readonly ["GET", "POST"]
as const, TypeScript'e "bu değer hiç değişmeyecek, tipini olabildiğince dar çıkar" der. Etkisi iki yönlüdür: bütün property'ler readonly olur ve hiçbir literal genişlemez. Bu yüzden options.method'un tipi artık "GET"'tir. Array'e uygulandığında da sonuç, daha önce gördüğümüz readonly tuple'dır: element'lerin sırası ve değerleri tipe işlenir.
İstersen as const'ı yalnızca tek bir property'ye de uygulayabilirsin: { url: "/api/users", method: "GET" as const }. Bu durumda yalnızca method dar kalır, url sıradan bir string olur.
as const'ın bir de güçlü yan etkisi var: bir array'den otomatik olarak literal union türetmek. Bunun için gereken typeof ve indexed access araçlarını Orta seride göreceğiz. as const'ın kendisine, satisfies ve type assertion'larla birlikte ilerleyen bir yazıda daha ayrıntılı döneceğiz.
Union'larla Çalışırken Neden Daraltma Gerekir?
formatId'e geri dönelim. Fonksiyon gerçekten iki tipi de kabul etmeli ve string geldiğinde büyük harfe çevirmeli. Bunu yapmak için TypeScript'e, kodun belli bir noktasında değerin hangi member olduğunu göstermen gerekir:
function formatId(id: string | number) {
if (typeof id === "string") {
// burada id: string
return id.toUpperCase();
}
// burada id: number
return id.toFixed(0);
}
console.log(formatId("ab-7")); // AB-7
console.log(formatId(42)); // 42
JavaScript serilerinden bildiğin typeof kontrolü burada iki iş yapar. Çalışma zamanında doğru dalı seçer; derleme zamanında ise TypeScript bu kontrolü okur ve if bloğunun içinde id'nin artık yalnızca string olabileceğini anlar. Blok return ile bittiği için, bloğun altına gelindiğinde geriye tek ihtimal kalır: number.
Bu işleme narrowing, yani daraltma denir. Union, "bu değer birkaç şeyden biri olabilir" der; narrowing ise kodun her noktasında "şu an tam olarak hangisi" sorusuna cevap verir. Union'ı olan her kodda bir noktada narrowing yapman gerekir; aksi hâlde yalnızca ortak işlemlerle yetinmek zorunda kalırsın.
Sık Düşülen Tuzaklar
Birinci tuzak, parantezin yeri. (string | number)[] ile string[] | number[] aynı şey değildir:
const mixed: (string | number)[] = [1, "two", 3];
// Hata: Type '(string | number)[]' is not assignable to type 'string[] | number[]'.
const uniform: string[] | number[] = [1, "two", 3];
İlki, her element'i string ya da number olabilen tek bir array'dir; karışık içerik serbesttir. İkincisi ise "ya tamamı string olan bir array ya da tamamı number olan bir array" der. Ne istediğine karar verip parantezi ona göre koy.
İkinci tuzak, literal'in geniş tipin içinde kaybolması. type Size = string | "small" yazarsan TypeScript bunu sadece string olarak tutar; "small" zaten bir string olduğu için union'a hiçbir şey eklemez. Editörün otomatik tamamlaması da bu yüzden "small"'ı önermez. "Belirli birkaç değer artı serbest string" istiyorsan bu, union ile ifade edilemeyen bir şeydir; ya listeyi kapat ya da serbest string'e razı ol.
Üçüncü tuzak, object union'larında property'ye doğrudan erişmek:
type Circle = { radius: number };
type Square = { size: number };
function area(shape: Circle | Square) {
// Hata: Property 'radius' does not exist on type 'Circle | Square'.
return Math.PI * shape.radius ** 2;
}
Kural yine aynı: radius yalnızca Circle'da var, Square'de yok. Object union'larını daraltmanın en temiz yolu, her member'a ortak bir "etiket" property'si vermektir. Buna discriminated union denir ve bir sonraki yazının önemli konularından biri.
Küçük Bir Deneme
★☆☆ Yazıdaki Theme ve setTheme örneğini editörüne yapıştır. setTheme(" yazdığında hangi önerilerin geldiğine bak, sonra Theme'e "sepia" ekle ve önerilerin nasıl değiştiğini gör.
★★☆ Type'ı ne, tahmin et: const sizes = ["s", "m", "l"]; ile const sizes = ["s", "m", "l"] as const; satırlarında sizes'ın üzerine gel. İki tip arasında kaç fark sayabiliyorsun?
★★☆ Derlenir mi: let flag: true | false = true; yazdıktan sonra flag = Math.random() > 0.5; satırı hata verir mi? Cevabını boolean'ın nasıl tutulduğunu düşünerek ver.
★★★ request örneğini hatalı hâliyle al ve üç farklı şekilde düzelt: object'e type annotation vererek, yalnızca method'a as const ekleyerek ve bütün object'e as const ekleyerek. Her birinde options.url = "/api/admins"; satırını dene. Hangisi buna izin veriyor?
Bu yazıda union'ın bir ihtimaller listesi olduğunu ve TypeScript'in ancak bu ihtimalleri daralttığında sana tam güç verdiğini gördük. Bir sonraki yazıda narrowing'in bütün araçlarına bakacağız: typeof'un yanında truthiness, eşitlik, in ve instanceof kontrolleri, discriminated union'lar ve bir union'ın her member'ını ele aldığından emin olmanın yolu.