Types pour les fonctions async et Promise en TypeScript

1. Aperçu

Les fonctions async sont présentes dans la plupart du code TypeScript moderne : appeler des API, lire une base de données, traiter les files d’attente. Écrire une fonction async est facile. Déclarer les types pour qu’un compilateur détecte les erreurs est plus difficile.

Cet article vous montre comment typer les fonctions async et Promise. Les principaux sujets comprennent Promise<T>, Awaited<T>, les types de Promise.all et Promise.allSettled, comment gérer les erreurs typées, et les helpers génériques comme le timeout. Une fois lu, vous éviterez les erreurs subtiles comme oublier await ou utiliser async dans forEach.

2. Préparation de l’environnement

Les exemples utilisent TypeScript en mode strict et Vitest pour les tests :

npm init -y
npm install -D typescript vitest
npx tsc --init --strict --target es2022 --module nodenext

Le drapeau --strict active également useUnknownInCatchVariables. Ce drapeau est important pour la section de gestion des erreurs au point 5.

Tous les exemples utilisent un modèle simple partagé et une fonction qui simule la latence réseau :

// models.ts
export interface Order {
  id: string;
  customerId: string;
  total: number;
  status: 'PENDING' | 'PAID';
}

export interface Customer {
  id: string;
  name: string;
}

export const delay = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

delay retourne Promise<void>. C’est le type d’une Promise qui signifie « terminée » sans transporter de valeur. Ici, vous devez spécifier new Promise<void>. Sans cela, TypeScript déduit Promise<unknown>.

3. Types de retour des fonctions async

3.1. Toujours Promise<T>

Une fonction async retourne toujours une Promise. La valeur que vous return à l’intérieur sera enveloppée dedans :

// order-service.ts
import { Order, Customer, delay } from './models';

const orders = new Map<string, Order>([
  ['o-1', { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PENDING' }],
]);

export async function fetchOrder(id: string): Promise<Order> {
  await delay(50);
  const order = orders.get(id);
  if (!order) {
    throw new Error(`Order ${id} not found`);
  }
  return order; // type Order, enveloppé dans Promise<Order>
}

export async function fetchCustomer(id: string): Promise<Customer> {
  await delay(30);
  return { id, name: 'Nguyễn Văn An' };
}

Le type déclaré est Promise<Order> et non Order. Si vous écrivez async function fetchOrder(id: string): Order, le compilateur affiche une erreur :

error TS1064: The return type of an async function or method must be the global Promise<T> type.

L’instruction throw à l’intérieur d’une fonction async ne modifie pas le type de retour. L’erreur devient une Promise rejetée. L’appelant la capture soit avec try/catch autour de await, soit avec .catch().

3.2. Inférence ou déclaration explicite ?

TypeScript déduit automatiquement le type de retour si vous omettez l’annotation. Néanmoins, pour les fonctions publiques, vous devriez déclarer le type explicitement. Regardez cet exemple :

async function findOrder(id: string) {
  const order = orders.get(id);
  if (!order) return null;
  return order;
}
// Type déduit : Promise<Order | null>

Ce type est correct, mais il dépend du corps de la fonction. Supposons que quelqu’un change la première branche pour return undefined. Le type changera silencieusement en Promise<Order | undefined>, et tous les appels qui vérifient === null seront bugués sans aucun avertissement. Lorsque vous déclarez Promise<Order | null> explicitement, le compilateur signale l’erreur immédiatement dans la fonction.

4. Extraire le type « à l’intérieur » d’une Promise avec Awaited<T>

Parfois, vous avez une fonction async et vous avez besoin du type de la valeur après await. ReturnType n’est pas suffisant, car il retourne la Promise elle-même :

import { fetchOrder } from './order-service';

type R1 = ReturnType<typeof fetchOrder>;          // Promise<Order>
type R2 = Awaited<ReturnType<typeof fetchOrder>>; // Order

type Nested = Awaited<Promise<Promise<number>>>;  // number

Awaited<T> simule correctement le comportement de await. Il « déploie » les Promises de manière récursive, même lorsque les Promises sont imbriquées. Ce type est très utile lorsque vous écrivez du générique, ou lorsqu’une bibliothèque n’exporte pas le type du résultat :

type OrderSummary = Pick<Awaited<ReturnType<typeof fetchOrder>>, 'id' | 'total'>;
// { id: string; total: number }

5. Types pour les erreurs : Promise n’a pas de paramètre d’erreur

Promise<T> n’a qu’un paramètre de type, réservé pour la valeur en cas de succès. Il n’y a pas de syntaxe Promise<Order, NotFoundError>. Par conséquent, la variable dans catch a le type unknown :

async function printOrder(id: string): Promise<void> {
  try {
    const order = await fetchOrder(id);
    console.log(order.total);
  } catch (err) {
    // err: unknown
    if (err instanceof Error) {
      console.error('Erreur :', err.message);
    } else {
      console.error('Erreur inconnue :', err);
    }
  }
}

await printOrder('o-404');
Erreur : Order o-404 not found

JavaScript permet de throw n’importe quelle valeur : une chaîne, un nombre, un objet. Par conséquent, unknown est le seul type honnête pour err. Vous devez affiner le type avec instanceof avant d’accéder à err.message.

Si vous voulez que l’erreur fasse partie du type, vous pouvez utiliser le pattern Result :

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

async function attempt<T>(promise: Promise<T>): Promise<Result<T>> {
  try {
    return { ok: true, value: await promise };
  } catch (err) {
    const error = err instanceof Error ? err : new Error(String(err));
    return { ok: false, error };
  }
}

const result = await attempt(fetchOrder('o-1'));
if (result.ok) {
  console.log(result.value.status); // result.value: Order
} else {
  console.log(result.error.message);
}
PENDING

La propriété ok est un discriminant. Une fois que vous vérifiez if (result.ok), TypeScript sait avec certitude que result.value existe et a le type Order. L’appelant ne peut pas « oublier » de gérer l’erreur, car pour obtenir value, il faut d’abord vérifier ok.

Note : N’enveloppez pas chaque fonction avec Result. Ce pattern convient aux erreurs métier prévisibles, comme « non trouvé » ou « rupture de stock ». Pour les erreurs d’infrastructure comme une connexion DB perdue, laissez l’exception remonter vers une couche de gestion centralisée.

6. Types de Promise.all, allSettled et race

6.1. Promise.all préserve le type tuple

Lorsque vous passez un tableau littéral, TypeScript déduit le type pour chaque position :

const [order, customer] = await Promise.all([
  fetchOrder('o-1'),
  fetchCustomer('c-1'),
]);
// order: Order, customer: Customer

console.log(`${customer.name} - ${order.total}`);
Nguyễn Văn An - 250000

Les deux requêtes s’exécutent en parallèle. Le temps total est d’environ 50 ms, pas 80 ms. Le type du résultat est [Order, Customer], pas (Order | Customer)[]. Grâce à cela, vous pouvez destructurer sans avoir besoin de cast.

Ceci n’est vrai que lorsque vous passez le tableau directement. Si vous créez le tableau d’abord puis le passez, le type tuple est perdu :

const tasks = [fetchOrder('o-1'), fetchCustomer('c-1')];
const results = await Promise.all(tasks);
// results: (Order | Customer)[]  -- information de position perdue

const tasksTuple = [fetchOrder('o-1'), fetchCustomer('c-1')] as const;
const [o, c] = await Promise.all(tasksTuple); // Order, Customer

Ajouter as const aide à préserver le type tuple.

6.2. Promise.allSettled et affinage du type

Promise.allSettled ne rejette pas. Chaque élément du résultat a le type PromiseSettledResult<T> :

const settled = await Promise.allSettled([
  fetchOrder('o-1'),
  fetchOrder('o-404'),
]);

for (const r of settled) {
  if (r.status === 'fulfilled') {
    console.log('OK', r.value.id);    // r.value: Order
  } else {
    console.log('FAIL', r.reason);    // r.reason: any
  }
}
OK o-1
FAIL Error: Order o-404 not found

r.reason a le type any, pas unknown. C’est une petite brèche dans la bibliothèque standard, donc vous devez toujours vérifier instanceof Error avant d’utiliser.

6.3. Promise.race et helper timeout générique

Promise.race retourne un type union des Promises. Combiné avec Promise<never>, nous avons un helper timeout qui préserve le type :

export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout après ${ms}ms`)), ms),
  );
  return Promise.race([promise, timeout]);
}

const order = await withTimeout(fetchOrder('o-1'), 1000); // order: Order

Puisque timeout a le type Promise<never>, le résultat de race est Promise<T | never>, c’est-à-dire Promise<T>. never disparaît de l’union de types. C’est exactement ce que never signifie : « cette branche ne retourne jamais de valeur ».

Note : L’exemple ci-dessus ne nettoie pas setTimeout lorsque promise se termine en premier. Dans le code production, vous devriez sauvegarder l’id du timer et appeler clearTimeout dans finally. Une autre approche est d’utiliser AbortSignal.timeout(ms) si l’API sous-jacente supporte signal.

7. Types pour les callbacks async

7.1. Le piège () => void

TypeScript permet d’assigner une fonction retournant Promise<void> à un type () => void. Cela semble inoffensif mais peut masquer des erreurs :

function onOrderPaid(handler: (order: Order) => void) {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  handler(order); // la Promise retournée est ignorée
  console.log('Handler appelé');
}

onOrderPaid(async (order) => {
  await delay(10);
  throw new Error('Envoi d\'email échoué'); // unhandled rejection!
});
Handler appelé
Error: Envoi d'email échoué   (unhandled rejection, peut faire planter le processus Node)

onOrderPaid ne sait pas que le handler est async. Il n’en fait pas await, donc l’erreur n’est capturée par personne. La façon de corriger est de déclarer explicitement que le handler peut être async, puis d’en faire await :

type OrderHandler = (order: Order) => void | Promise<void>;

async function onOrderPaidSafe(handler: OrderHandler): Promise<void> {
  const order: Order = { id: 'o-1', customerId: 'c-1', total: 250_000, status: 'PAID' };
  await handler(order); // attendre une valeur qui n'est pas une Promise est valide
}

Le type void | Promise<void> accepte les deux handlers synchrones et asynchrones. await sur une valeur synchrone la retourne simplement.

7.2. async dans forEach

C’est l’erreur la plus courante. forEach accepte (item) => void donc il ignore les Promises :

async function payAll(list: Order[]): Promise<void> {
  list.forEach(async (o) => {
    await delay(20);
    o.status = 'PAID';
  });
  console.log(list.map((o) => o.status)); // toujours ['PENDING', ...]
}

Il y a deux façons de corriger, selon vos besoins :

// Séquentiellement : une commande à la fois
for (const o of list) {
  await delay(20);
  o.status = 'PAID';
}

// En parallèle : le type de retour est Promise<void[]>
await Promise.all(
  list.map(async (o) => {
    await delay(20);
    o.status = 'PAID';
  }),
);

Pour attraper automatiquement ces erreurs de type, activez les règles @typescript-eslint/no-floating-promises et @typescript-eslint/no-misused-promises dans typescript-eslint. Le compilateur TypeScript seul ne signale pas les erreurs dans ces cas.

8. Tester le comportement et les types avec Vitest

Vitest permet de tester à la fois la valeur à l’exécution et les types à la compilation :

// order-service.test.ts
import { describe, it, expect, expectTypeOf } from 'vitest';
import { fetchOrder, fetchCustomer } from './order-service';
import { withTimeout } from './with-timeout';
import { delay, type Order } from './models';

describe('order-service', () => {
  it('retourne une commande lorsqu\'elle existe', async () => {
    await expect(fetchOrder('o-1')).resolves.toMatchObject({
      id: 'o-1',
      status: 'PENDING',
    });
  });

  it('rejette lorsque la commande n\'est pas trouvée', async () => {
    await expect(fetchOrder('o-404')).rejects.toThrow('Order o-404 not found');
  });

  it('withTimeout rejette lorsque la promise prend trop de temps', async () => {
    const slow = delay(200).then(() => 'done');
    await expect(withTimeout(slow, 50)).rejects.toThrow('Timeout après 50ms');
  });

  it('conserve le type correct pour Promise.all et withTimeout', () => {
    expectTypeOf(fetchOrder).returns.resolves.toEqualTypeOf<Order>();
    expectTypeOf(withTimeout(fetchOrder('o-1'), 100)).resolves.toEqualTypeOf<Order>();
    expectTypeOf(
      Promise.all([fetchOrder('o-1'), fetchCustomer('c-1')]),
    ).resolves.toMatchTypeOf<[Order, { name: string }]>();
  });
});

Exécutez les tests :

npx vitest run --typecheck
 ✓ order-service.test.ts (4 tests)
 Test Files  1 passed (1)
      Tests  4 passed (4)

Il y a trois points à noter :

  • Toujours await avant expect(...).resolves/.rejects. Sinon, le test se termine avant que la Promise ne soit résolue et passe toujours.
  • .rejects.toThrow(...) vérifie le message de l’erreur rejetée.
  • expectTypeOf n’exécute aucune logique à l’exécution. Le drapeau --typecheck demande à Vitest d’exécuter tsc pour confirmer les types. Si quelqu’un change withTimeout pour retourner Promise<unknown>, ce test échoue.

9. Erreurs courantes

Erreur Conséquence Correction
Oublier await lors de l’appel d’une fonction async La variable a le type Promise<Order> au lieu de Order ; l’erreur est masquée Activez no-floating-promises ; lisez attentivement le type au survol
if (fetchOrder(id)) Toujours true car une Promise est un objet TypeScript 5.x signale une condition toujours vraie ; faites await avant
async dans forEach N’attend pas, les erreurs ne sont pas capturées for...of ou Promise.all(map)
Accéder à err.message dans catch Erreur de compilation en mode strict Affinez avec instanceof Error
Caster avec as Promise<Order> pour les données de fetch Le type « ment », les erreurs apparaissent à l’exécution Validez les données avec un schéma avant de typer

La dernière erreur mérite une note supplémentaire. await response.json() retourne Promise<any>. Écrire const order: Order = await res.json() compile, mais TypeScript ne vérifie rien. Les données provenant de l’extérieur du processus doivent toujours être validées.

Note : Pas chaque fonction retournant une Promise n’a besoin du mot-clé async. Si la fonction fait juste relayer une Promise, par exemple return repository.find(id), vous pouvez omettre async. Gardez async lorsque la fonction contient try/catch autour de await. Si vous omettez async et await dans ce cas, catch n’attrapera pas les erreurs rejetées.

10. Conclusion

Une fonction async retourne toujours Promise<T>. Déclarez ce type explicitement pour les API publiques. Utilisez Awaited<T> lorsque vous avez besoin d’extraire le type d’une Promise « déployée ». Promise.all préserve le type tuple si vous passez un tableau littéral ou utilisez as const. Combinez Promise.race avec Promise<never> pour écrire des helpers génériques sans perdre le type.

Concernant les erreurs, Promise ne porte pas le type de l’erreur. La variable dans catch est unknown, et le pattern Result est un choix lorsque vous voulez que les erreurs métier apparaissent clairement dans le type. Enfin, soyez attentif avec les callbacks typés () => void et forEach. Activez les règles ESLint sur les Promises pour attraper automatiquement les erreurs que le compilateur ignore.

typescriptasyncpromisegenericsvitest