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

TypeScript ile Asenkron Kod: Promise, async ve fetch Cevabını Tiplemek

Mert Özen 3 Kas 2026 10 dk 1 görüntülenme
TypeScript ile Asenkron Kod: Promise, async ve fetch Cevabını Tiplemek

Promise<T> ve async dönüş tiplerini, fetch cevabının neden any olduğunu, unknown ve elle yazılan type guard ile runtime doğrulamayı ve Promise.all ile allSettled tiplerini görüyoruz.

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

Önce Tahmin Et

JavaScript serilerinde async fonksiyonları ve promise'leri ayrıntılı konuşmuştuk. Şimdi o bilgiyi tip sistemiyle buluşturalım:

async function getCount() {
  return 42;
}

const count = getCount();
console.log(count + 1);

Bu kod derlenir mi? Derleniyorsa konsola ne yazar, derlenmiyorsa TypeScript ne der? İpucu: count'un type'ını düşün.

Promise<T>: Henüz Gelmemiş Bir Değerin Tipi

Promise, Orta serinin başından beri konuştuğumuz generic'lerin en tanıdık örneklerinden biri. Promise<T>, "ileride T tipinde bir değer verecek olan bir object" demektir. Bir kargo takip numarası gibi düşün: paketin içinde ne olduğu belli, ama paket henüz elinde değil. Takip numarasıyla paketi açamazsın; önce paketin gelmesini beklemen gerekir.

function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function rollDice(): Promise<number> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(Math.ceil(Math.random() * 6)), 100);
  });
}

const untyped = new Promise((resolve) => resolve(42)); // Promise<unknown>
const typed = new Promise<number>((resolve) => resolve(42)); // Promise<number>

// Hata: Argument of type 'string' is not assignable to parameter of type 'number | PromiseLike<number>'.
const wrong = new Promise<number>((resolve) => resolve("42"));

wait bir değer vermez, sadece "bitti" der; bu yüzden Promise<void>. rollDice bir sayı verecek. Bu iki fonksiyonda new Promise'e tip parametresi vermedik, çünkü dönüş tipi annotation'ı TypeScript'e ne beklediğini zaten söylüyor.

untyped satırı ise küçük bir tuzak. new Promise'i tek başına, hiçbir bağlam olmadan yazarsan TypeScript resolve'a verdiğin değerden tip çıkarmaz ve sonuç Promise<unknown> olur. Ya tip parametresini açıkça ver, ya da fonksiyonun dönüş tipini yaz. Tip parametresini verdiğinde resolve da kontrol altına girer; wrong satırındaki gibi yanlış tipte bir değerle çağıramazsın.

async Fonksiyonların Dönüş Tipi

Baştaki soruya dönelim. Bir async fonksiyon, içinde ne döndürürsen döndür, her zaman bir Promise döndürür. getCount içinde 42 yazıyor, ama fonksiyonun dönüş tipi Promise<number>. count bir sayı değil, bir sayının takip numarası. Bu yüzden kod derlenmez:

error TS2365: Operator '+' cannot be applied to types 'Promise<number>' and '1'.

Editörde aynı hatanın altında bir ipucu da görürsün: "Did you forget to use 'await'?". Düz JavaScript'te bu kod çalışır ve konsola [object Promise]1 yazardı; JavaScript serilerinden tanıdığın coercion yine sessizce devreye girerdi. Doğru hâli şöyle:

async function getCount() {
  return 42;
}

async function main() {
  const count = await getCount(); // number
  console.log(count + 1); // 43
}

main();

// Hata: The return type of an async function or method must be the global Promise<T> type.
async function getTotal(): number {
  return 100;
}

await, Promise<number>'ı açar ve sana number verir. Daha önce gördüğümüz Awaited'ın değer seviyesindeki karşılığı bu. Son fonksiyon da sık yapılan bir hata: async bir fonksiyonun dönüş tipini annotation ile yazıyorsan, içteki değerin tipini değil Promise<number> yazmalısın. Derleyici mesajın devamında bunu sana önerir.

Dönüş tipini ne zaman yazmalısın? Temel serideki kural burada da geçerli: küçük yardımcı fonksiyonlarda inference yeterli, ama başka dosyaların kullandığı, dış dünyayla konuşan fonksiyonlarda Promise<Todo> gibi açık bir dönüş tipi yazmak hem belge olur hem de içeride yanlışlıkla başka bir şey döndürmeni engeller.

fetch Cevabı Neden any?

Gerçek bir API ile çalışalım. Örneklerde, test için herkese açık olan jsonplaceholder.typicode.com adresini kullanacağız. /todos/1 adresi şu JSON'u döndürüyor: { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }.

type Todo = { userId: number; id: number; title: string; completed: boolean };

async function getTodoUnsafe(id: number) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  const data = await response.json(); // any
  console.log(data.titel); // undefined
  return data;
}

async function getTodoTrusting(id: number): Promise<Todo> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  return response.json();
}

getTodoUnsafe(1);

fetch'in kendisi iyi tiplenmiştir: Promise<Response> döndürür, response.ok bir boolean, response.status bir number'dır. Ama response.json()'ın dönüş tipi Promise<any>. Bu mantıklı: TypeScript sunucunun ne göndereceğini bilemez. Böylece data, Temel seride kaçınmamız gerektiğini söylediğimiz any oluyor. data.titel gibi bir yazım hatası derlenir ve çalışma zamanında sessizce undefined verir.

getTodoTrusting daha iyi görünüyor, ama değil. Dönüş tipine Promise<Todo> yazdık ve any her şeye atanabildiği için TypeScript hiç itiraz etmedi. Bu, kılık değiştirmiş bir type assertion'dır: "sunucu kesinlikle bir Todo gönderir" demiş olduk, ama hiçbir şeyi kontrol etmedik. API bir gün title'ı name olarak değiştirirse, kodun her yerinde Todo tipiyle ama yanlış bir veriyle çalışırsın. Aynı şey response.json() as Todo ya da getJson<T>() gibi generic bir yardımcı için de geçerli; generic, assertion'ı sadece başka bir yere taşır.

unknown ile Başla, Elle Doğrula

Güvenli yol, dışarıdan gelen veriyi unknown kabul edip bir önceki yazıda gördüğümüz type guard'larla doğrulamak:

type Todo = { userId: number; id: number; title: string; completed: boolean };

function isTodo(value: unknown): value is Todo {
  return (
    typeof value === "object" &&
    value !== null &&
    "userId" in value && typeof value.userId === "number" &&
    "id" in value && typeof value.id === "number" &&
    "title" in value && typeof value.title === "string" &&
    "completed" in value && typeof value.completed === "boolean"
  );
}

async function fetchTodo(id: number): Promise<Todo> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }
  const data: unknown = await response.json();
  if (!isTodo(data)) {
    throw new Error("Unexpected response shape");
  }
  return data;
}

async function main() {
  const todo = await fetchTodo(1);
  console.log(todo.title); // delectus aut autem

  try {
    await fetchTodo(999);
  } catch (error) {
    console.log(error instanceof Error ? error.message : error); // Request failed with status 404
  }
}

main();

Önemli satır const data: unknown = await response.json();. any'yi bilerek unknown'a çeviriyoruz; artık data üzerinde hiçbir şey yapamayız, önce kontrol etmemiz gerekir. isTodo o kontrolü yapar. İçindeki "id" in value && typeof value.id === "number" kalıbı TypeScript 4.9'dan beri çalışıyor: in kontrolünden sonra TypeScript, value'nun bir id property'si olduğunu bilir ve onun tipini kontrol etmene izin verir. Kontrol geçtikten sonra data bir Todo'dur ve dönüş tipine uyar.

İki ayrıntı daha var. Birincisi response.ok kontrolü: JavaScript serilerinden hatırlarsın, fetch sadece ağ hatalarında reject eder; 404 gibi bir HTTP hatası yine başarılı bir Response olarak gelir. jsonplaceholder'da var olmayan /todos/999 404 döndürür ve biz onu kendi hatamıza çeviririz. İkincisi catch içindeki error: strict modda tipi unknown'dur, bu yüzden message'ı okumadan önce instanceof Error ile daraltıyoruz. Hata yönetimini ayrı bir yazıda konuşacağız.

Gerçek projelerde bu kontrolleri elle yazmak yerine şema doğrulama library'leri kullanılır; şemayı bir kez tanımlarsın, hem type hem de doğrulama fonksiyonu ondan çıkar. Ama altta yatan fikir aynı ve önce elle yazıp neyi çözdüklerini görmek değerli.

Birden Fazla İstek: Promise.all ve Promise.allSettled

Aynı kalıpla bir kullanıcıyı ve onun todo listesini getiren iki fonksiyon daha yazalım. Yukarıdaki Todo ve isTodo'yu kullanıyoruz:

type User = { id: number; name: string; email: string };

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "number" &&
    "name" in value && typeof value.name === "string" &&
    "email" in value && typeof value.email === "string"
  );
}

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  const data: unknown = await response.json();
  if (!isUser(data)) throw new Error("Unexpected user shape");
  return data;
}

async function fetchTodos(userId: number): Promise<Todo[]> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos?userId=${userId}`);
  const data: unknown = await response.json();
  if (!Array.isArray(data) || !data.every(isTodo)) throw new Error("Unexpected todos shape");
  return data;
}

/users/1 aslında çok daha fazla alan döndürüyor: adres, telefon, şirket bilgisi. isUser sadece ihtiyacımız olan üç alana bakıyor; structural typing sayesinde fazla alanlar sorun değil. fetchTodos'ta ise every'nin bir özelliğinden yararlandık: callback bir type predicate olduğunda every de array'i daraltır. Array.isArray ve data.every(isTodo) geçtikten sonra data, Todo[]'dur.

Şimdi ikisini paralel çalıştıralım:

async function loadDashboard(userId: number) {
  const [user, todos] = await Promise.all([fetchUser(userId), fetchTodos(userId)]);
  // user: User, todos: Todo[]
  const done = todos.filter((todo) => todo.completed).length;
  console.log(`${user.name}: ${done}/${todos.length} done`); // Leanne Graham: 11/20 done
}

async function loadMany(ids: number[]) {
  const results = await Promise.allSettled(ids.map((id) => fetchTodo(id)));
  for (const result of results) {
    if (result.status === "fulfilled") {
      console.log("ok", result.value.id);
    } else {
      console.log("failed", result.reason instanceof Error ? result.reason.message : result.reason);
    }
  }
}

loadDashboard(1).then(() => loadMany([1, 999]));
// ok 1
// failed Request failed with status 404

Promise.all'a bir array literal verdiğinde TypeScript sonucu bir tuple olarak tipler: [User, Todo[]]. Her pozisyon kendi tipini korur, bu yüzden destructuring'den sonra user bir User, todos bir Todo[] olur; (User | Todo[])[] gibi işe yaramaz bir karışım almazsın. ids.map(...) gibi uzunluğu belli olmayan bir array verdiğinde ise sonuç düz bir array olur.

Promise.allSettled hiçbir isteğin hatasıyla durmaz ve her sonuç için PromiseSettledResult<Todo> döndürür. Bu bir discriminated union: status alanı "fulfilled" ise value, "rejected" ise reason vardır. Temel seride gördüğümüz narrowing burada da aynen işler. Yalnız reason'ın tipi any'dir; promise'ler her şeyle reject edilebildiği için TypeScript daha iyisini bilemez. Onu da error gibi kontrol ederek kullanmak en doğrusu.

Sık Düşülen Tuzaklar

async function isLoggedIn() {
  return false;
}

async function greet() {
  // Hata: This condition will always return true since this 'Promise<boolean>' is always defined.
  if (isLoggedIn()) {
    console.log("Welcome back");
  }
}

async function saveAll(ids: number[]) {
  ids.forEach(async (id) => {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log("saved", id);
  });
  console.log("all saved?");
}

saveAll([1, 2]);
// all saved?
// saved 1
// saved 2

Birincisi await'i bir koşulda unutmak. Bir Promise object'i her zaman truthy'dir, yani if (isLoggedIn()) her zaman çalışır. TypeScript bunu yakalar ve TS2801 hatasıyla birlikte yine "Did you forget to use 'await'?" ipucunu verir. Bu iyi haber; kötü haber, her unutulan await bu kadar görünür değil. Sonucu hiç kullanılmayan bir promise'i (floating promise) tsc yakalamaz; bunun için linter kuralları vardır.

İkincisi forEach içinde async callback. Bu kod tertemiz derlenir, çünkü forEach callback'inin dönüş tipi void'dir ve Temel seride gördüğümüz gibi void dönen bir callback yerine değer dönen bir fonksiyon verebilirsin. Ama forEach dönen promise'leri beklemez; çıktıda "all saved?" kayıtlardan önce görünür. Sırayla beklemek istiyorsan for...of içinde await, paralel istiyorsan await Promise.all(ids.map(...)) kullan.

Üçüncüsü yukarıda gördüğümüz Promise<Todo> yalanı. response.json()'ı bir type'a atamak ya da as ile cast etmek veriyi doğrulamaz. Ağdan gelen her şey, sen kontrol edene kadar unknown'dur.

Küçük Bir Deneme

★☆☆ Tahmin et, sonra editörde fonksiyonun üzerine gelerek doğrula: async function maybe(flag: boolean) { if (flag) return "yes"; } fonksiyonunun dönüş tipi nedir?

★★☆ jsonplaceholder'daki /posts/1 adresi { userId, id, title, body } alanlarını döndürüyor. Bir Post type'ı, bir isPost type guard'ı ve Promise<Post> döndüren bir fetchPost fonksiyonu yaz. Sonra isPost'ta bilerek body yerine content kontrol et ve hatanın nerede ortaya çıktığını gör.

★★★ Bu yazıdaki fonksiyonların hepsi aynı kalıbı tekrar ediyor. Bir URL ve bir type guard alan generic bir fetchValidated fonksiyonu yaz: fetchValidated("https://jsonplaceholder.typicode.com/todos/1", isTodo) çağrısının dönüş tipi Promise<Todo> olsun. İpucu: guard parametresinin tipi (value: unknown) => value is T.

Bu yazıda Todo, isTodo, fetchTodo ve main hep aynı dosyada yaşadı. Gerçek bir projede type'lar bir dosyada, guard'lar başka bir dosyada, API çağrıları da bir üçüncüsünde durur. Bir sonraki yazıda module'lere geçiyoruz: TypeScript'te import ve export, sadece type taşıyan import type ve node file.ts ile çalışırken bunun neden önemli olduğu.