Перейти к содержимому
Теория · Асинхронный код и данные извне · урок 3 из 10

Promise.all и колбэки

В прошлом уроке был один заказ и один чек. Часто заказов несколько: пользователь и число его покупок приходят с сервера по отдельности.

Представьте, что в кафе вы заказали суп у одного окна и чай у другого. Ждать их по очереди незачем: обе кухни работают одновременно. Удобнее сдать оба чека сразу и получить поднос, на котором стоят оба блюда — в том порядке, в каком вы сдали чеки.

Promise.all собирает такой поднос. Он принимает массив промисов и возвращает один промис, который выдаст массив результатов в том же порядке:

TypeScript
type User = { name: string };

async function loadUser(): Promise<User> {
    return { name: "Anna" };
}

async function loadCount(): Promise<number> {
    return 3;
}

async function main() {
    const [user, count] = await Promise.all([loadUser(), loadCount()]);
    console.log(user.name, count); // Anna 3
}
main();

Давайте разберём, что происходит в main:

  1. loadUser() и loadCount() возвращают Promise<User> и Promise<number>.
  2. Promise.all([...]) получает оба промиса: обе функции уже вызваны и работают одновременно.
  3. await выдаёт поднос: массив, где первым стоит пользователь, а вторым — число.
  4. Деструктуризация [user, count] раскладывает его по переменным, и программа печатает Anna 3.

Поднос — это кортеж

Тип у подноса точный. Если сохранить результат в одну переменную, const pair = await Promise.all(...), и навести курсор на pair, подсказка редактора покажет [User, number]. Это кортеж: у каждой позиции свой тип.

  • позиция 0 — User, потому что первым в массиве стоит loadUser();
  • позиция 1 — number, потому что вторым стоит loadCount().

Поэтому user.name проходит проверку без единой аннотации. Будь тип массивом (number | User)[], как у обычного литерала из двух разных значений, каждый элемент пришлось бы сначала проверять. TypeScript не путает промисы местами: тип каждого результата он берёт из промиса на той же позиции.

API на колбэках

Промисы появились в JavaScript не сразу. Многие старые библиотеки и часть функций Node.js сообщают результат иначе — через колбэк, который вызывают, когда работа закончена:

TypeScript
type User = { name: string };

function loadUser(done: (err: Error | null, user?: User) => void): void {
    setTimeout(() => done(null, { name: "Anna" }), 10);
}

loadUser((err, user) => {
    if (err === null && user !== undefined) {
        console.log(user.name); // Anna
    }
});

Где здесь тип результата? Не в типе самой функции: loadUser возвращает void. Результат описан в параметрах колбэка done:

  • err: Error | null — ошибка или null, если всё прошло хорошо;
  • user?: User — результат; он необязательный, потому что при ошибке его нет.

Типы err и user в стрелочной функции TypeScript берёт из объявления done, и аннотации не нужны. Из-за user? внутри колбэка у user тип User | undefined, поэтому перед user.name стоит проверка.

API на промисах

Та же функция на промисах:

TypeScript
type User = { name: string };

function loadUser(): Promise<User> {
    return new Promise((resolve) => {
        setTimeout(() => resolve({ name: "Anna" }), 10);
    });
}

loadUser().then((user) => {
    console.log(user.name); // Anna
});

Здесь тип результата стоит в самом объявлении: Promise<User>. Колбэк в then получает user уже как User, без undefined и без параметра для ошибки. Сравним два способа:

  • колбэк: результат описан в параметрах колбэка, а сама функция возвращает void;
  • промис: функция возвращает Promise<User>, и тип результата стоит в угловых скобках.

Этот вопрос часто задают на собеседовании: как типизировать API на колбэках и чем от него отличается API на промисах.

Ошибку тип не описывает

В Promise<User> есть место только для результата: тип ошибки там не записан. Поэтому в .catch((e) => …) параметр e получает тип any, даже в строгом режиме. В колбэке ошибка хотя бы названа в параметре err: Error | null.

Здесь аналогия с подносом перестаёт работать. В кафе одно пропавшее блюдо не отменяет остальные, а Promise.all отклоняется целиком, как только отклонится любой из промисов. Тогда await вместо подноса выбросит ошибку.

Частая ошибка

Забыли await перед Promise.all:

TypeScript
async function loadCount(): Promise<number> {
    return 3;
}

async function main() {
    const [a, b] = Promise.all([loadCount(), loadCount()]); // ✗
    console.log(a + b);
}

main();
TEXT
error TS2488: Type 'Promise<[number, number]>' must have a '[Symbol.iterator]()' method that returns an iterator.

Сообщение длинное, но в нём есть подсказка: Promise<[number, number]>. Деструктуризация [a, b] ждёт массив, а получила промис — вместо подноса у вас всё ещё чек. Нужно await Promise.all(...).

Главное

  • await Promise.all([...]) выдаёт кортеж: тип каждой позиции берётся из промиса на той же позиции, например [User, number].
  • В API на колбэках результат описан в параметрах колбэка, например (err: Error | null, user?: User) => void, а функция возвращает void.
  • В API на промисах функция возвращает Promise<User>, и тип результата стоит в угловых скобках.
  • Тип ошибки промис не описывает: в .catch((e) => …) она приходит как any.