TypeScript types avances - 13 - Typer l'asynchrone : Promise, Awaited et AsyncGenerator

Comment typer correctement le code asynchrone en TypeScript. Promises génériques, Awaited, erreurs dans les catch, et les generators async.

13 - Typer l'asynchrone : Promise, Awaited et AsyncGenerator

Ce que tu vas apprendre

  • Comment Promise<T> propage les types à travers les then et await
  • Awaited<T> pour deballer les Promises imbriquees
  • Le problème du catch qui est toujours unknown
  • Les generateurs async : AsyncGenerator<T> et AsyncIterable<T>

Prerequisites

Avoir lu les articles sur les generics et les conditional types.


Promise : le generic le plus utilise

Chaque fonction async retourne une Promise<T> ou T est le type de la valeur résolue :

typescriptasync function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

const user = await getUser("123") // type: User

TypeScript infere le type de retour d'une fonction async à partir des return :

typescriptasync function fetchData() {
  const res = await fetch("/api/data")
  const json = await res.json()
  return json as { items: string[] }
}

// type infere : Promise<{ items: string[] }>

Promise.all, Promise.race, Promise.allSettled

TypeScript type correctement les combinateurs de Promises :

typescriptconst [user, orders, settings] = await Promise.all([
  getUser("123"),
  getOrders("123"),
  getSettings("123")
])
// user: User, orders: Order[], settings: Settings

const fastest = await Promise.race([
  fetchFromCDN(url),
  fetchFromOrigin(url)
])
// type: Response (le premier qui resolve)

const results = await Promise.allSettled([
  riskyOperation1(),
  riskyOperation2()
])
// type: [PromiseSettledResult<A>, PromiseSettledResult<B>]

Promise.allSettled retourne des PromiseSettledResult<T> qui sont des discriminated unions :

typescripttype PromiseSettledResult<T> =
  | { status: "fulfilled"; value: T }
  | { status: "rejected"; reason: any }

Tu peux filtrer les succes :

typescriptconst fulfilled = results
  .filter((r): r is PromiseFulfilledResult<string> => r.status === "fulfilled")
  .map(r => r.value)

Awaited

Awaited<T> deballe recursivement les Promises :

typescripttype A = Awaited<Promise<string>>              // string
type B = Awaited<Promise<Promise<number>>>     // number
type C = Awaited<string>                        // string (pas une Promise)
type D = Awaited<Promise<string> | number>     // string | number

C'est utile pour typer le résultat de await sur des types génériques :

typescriptasync function unwrap<T>(promise: T): Promise<Awaited<T>> {
  return await promise
}

Et pour extraire le type résolu d'une fonction async :

typescripttype UserData = Awaited<ReturnType<typeof getUser>> // User

Le problème du catch

En JavaScript, n'importe quoi peut etre lance : une Error, un string, un number, null. TypeScript ne peut pas savoir ce que throw lance dans une lib tierce.

typescripttry {
  await riskyOperation()
} catch (err) {
  // Avec strict: true, err est unknown
  // Tu DOIS verifier le type avant d'utiliser
  if (err instanceof Error) {
    console.log(err.message) // ✅
  }
}

Avant TypeScript 4.4 avec useUnknownInCatchVariables, err etait any par défaut. Maintenant avec strict: true, c'est unknown. C'est mieux mais ca force du boilerplate dans chaque catch.

Un pattern que j'utilise sur paltemps.fr pour simplifier :

typescriptfunction toError(err: unknown): Error {
  if (err instanceof Error) return err
  if (typeof err === "string") return new Error(err)
  return new Error(`Unknown error: ${JSON.stringify(err)}`)
}

try {
  await riskyOperation()
} catch (err) {
  const error = toError(err)
  console.log(error.message) // ✅ toujours safe
}

Le pattern Result pour éviter try/catch

Plutot que de lancer des exceptions, retourne un résultat type (vu dans l'article sur les discriminated unions) :

typescripttype Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E }

async function safeGetUser(id: string): Promise<Result<User>> {
  try {
    const user = await getUser(id)
    return { ok: true, value: user }
  } catch (err) {
    return { ok: false, error: toError(err) }
  }
}

const result = await safeGetUser("123")
if (result.ok) {
  console.log(result.value.name) // ✅ type: User
} else {
  console.log(result.error.message) // ✅ type: Error
}

L'erreur est dans le type de retour, pas dans le flux d'exécution. L'appelant est force de gerer les deux cas.

AsyncGenerator et AsyncIterable

Les generateurs async produisent des valeurs une par une, de manière asynchrone :

typescriptasync function* paginate<T>(
  fetchPage: (page: number) => Promise<T[]>
): AsyncGenerator<T> {
  let page = 1
  while (true) {
    const items = await fetchPage(page)
    if (items.length === 0) break
    for (const item of items) {
      yield item
    }
    page++
  }
}

// Utilisation
for await (const user of paginate(fetchUsers)) {
  console.log(user.name)
}

Le type AsyncGenerator<T> a trois paramètres génériques : AsyncGenerator<Yield, Return, Next>. En pratique, seul Yield est utilise.

AsyncIterable

AsyncIterable<T> est l'interface que for await...of attend. AsyncGenerator implemente AsyncIterable.

typescriptasync function processStream(stream: AsyncIterable<Uint8Array>) {
  for await (const chunk of stream) {
    process(chunk)
  }
}

C'est le type a utiliser dans les signatures de fonctions — plus générique que AsyncGenerator.

Typer un stream d'événements

typescriptasync function* eventStream(url: string): AsyncGenerator<ServerEvent> {
  const source = new EventSource(url)
  const queue: ServerEvent[] = []
  let resolve: (() => void) | null = null

  source.onmessage = (e) => {
    queue.push(JSON.parse(e.data))
    resolve?.()
  }

  while (true) {
    if (queue.length === 0) {
      await new Promise<void>(r => { resolve = r })
    }
    yield queue.shift()!
  }
}

for await (const event of eventStream("/api/events")) {
  match(event)
    .with({ type: "notification" }, handleNotification)
    .with({ type: "update" }, handleUpdate)
    .exhaustive()
}

Typer les callbacks async

Les callbacks async retournent Promise<T> :

typescripttype AsyncCallback<T> = () => Promise<T>

async function retry<T>(fn: AsyncCallback<T>, maxRetries: number): Promise<T> {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn()
    } catch (err) {
      if (i === maxRetries - 1) throw err
    }
  }
  throw new Error("Unreachable")
}

const user = await retry(() => getUser("123"), 3) // type: User

Le generic T propage le type du callback à travers retry.


Résumé

  • Promise<T> propage les types à travers await, then, et les combinateurs (all, race, allSettled)
  • Awaited<T> deballe les Promises recursivement — utile avec ReturnType
  • Le catch est toujours unknown avec strict: true — utilise une fonction toError ou le pattern Result
  • AsyncGenerator<T> produit des valeurs asynchrones une par une — utilise AsyncIterable<T> dans les signatures
  • Le pattern Result remplace try/catch par un retour type qui force la gestion des erreurs

Article précédent : 12 - Pattern matching

Article suivant : 14 - Type erasure : ce que TypeScript efface au runtime

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.