Асинхронность и промисы
TypeScript не добавляет собственного асинхронного рантайма — он типизирует ровно ту модель, что уже есть в JavaScript: Promise, async/await, thenable-объекты и асинхронные итераторы. Вся тема сводится к одному вопросу — где живут результат и ошибка. Callback-API кладёт результат в параметры колбэка, а сам возвращает void; Promise-API кладёт результат в аргумент типа Promise<T>. Успешный путь язык описывает точно и даже рекурсивно (Awaited<T> разворачивает вложенные промисы до конца), а вот путь ошибки не описывает вовсе: Promise<T> ничего не говорит о том, чем он может завершиться неудачей, поэтому пойманный reject — это any, а под useUnknownInCatchVariables — unknown.
Отсюда и ловушки, которые стоит назвать сразу. Тип возврата async-функции — всегда Promise, и аннотировать её голым T — ошибка; компилятор к тому же схлопывает возвращённый промис, поэтому Promise<Promise<T>> как значение не существует. Возвращаемый тип void у колбэка намеренно принимает функцию, возвращающую что угодно, — из-за этого arr.forEach(async x => …) спокойно компилируется, роняя каждый промис на пол. Колбэк, объявленный сокращённым синтаксисом метода, проверяет свой параметр бивариантно (нестрого), а объявленный стрелочным свойством — контравариантно под strictFunctionTypes. А наивный T extends Promise<infer U> ? U : T снимает лишь один слой и путается в thenable — именно поэтому в TS 4.5 появился встроенный Awaited<T>. Разбор по слоям — ниже.
Карта темы
- Типизация колбэков — почему пара
(err, data)— контракт хуже размеченного объединения, как контекстная типизация даёт параметрам колбэка типы даром и почемуvoidв возврате молча глотает промисы. - Типы промисов —
Promise<T>как контейнер, схлопывание в.then, различие результатовall/allSettled/race/anyи главная дыра — нетипизированный reject. - Вывод типа у async/await — почему возврат
async-функции всегдаPromise<T>, как компилятор схлопывает возвращённый промис и почемуthrowне виден в типе. - Утилита Awaited — рекурсивный условный тип, его настоящее определение, чем он лучше ручного
T extends Promise<infer U> ? U : Tи где он живёт в стандартной библиотеке. - Асинхронные генераторы —
async function*,AsyncGenerator<T, TReturn, TNext>, протоколSymbol.asyncIteratorиfor awaitдля потоковой выдачи с обратным давлением.
Частые ошибки и ловушки
| Ошибка | Последствие | |
|---|---|---|
Аннотировать async-функцию как : T вместо : Promise<T> | Ошибка типов: тело возвращает значение, а возврат async-функции всегда Promise | |
Ждать, что Promise<T> объявит тип ошибки, как Promise<T, E> | Reject не типизирован — второго аргумента типа у Promise нет, catch получает unknown | |
Типизировать err колбэка как Error | При успехе err равен null — контракт `(err: Error \ | null, data?: T)` |
| Считать, что результат callback-API виден в его типе возврата | Он в параметрах колбэка; сама функция возвращает void | |
| Объявлять колбэк-принимающий API сокращённым методом | Параметр колбэка проверяется бивариантно — тихая дыра; стрелочное свойство строже | |
Отдавать в forEach async-колбэк | Promise<void> подходит под void-позицию, промисы не ожидаются и молча теряются | |
Считать, что await снимает только один уровень | Тип результата — Awaited<T>, он разворачивает вложенные промисы рекурсивно | |
Думать, что .then вкладывает промисы | then схлопывает thenable: Promise<Promise<T>> как значение не существует | |
Писать T extends Promise<infer U> ? U : T вместо Awaited<T> | Снимает один слой, оставляет Promise<string> и не понимает произвольный thenable | |
Считать, что Awaited<T> что-то делает в рантайме | Тип полностью стирается — это не await, а разворачивание на уровне типов | |
Хранить кортеж промисов в const-переменной перед Promise.all | Литерал расширяется до массива объединения — перегрузка для кортежа не срабатывает | |
Считать, что Promise.allSettled даёт те же значения, что Promise.all | Элемент — PromiseSettledResult<T>, размеченный по status (fulfilled/rejected) | |
Обходить async function* через обычный for...of | Его next() даёт Promise<IteratorResult>; нужен только for await | |
Ждать от async function* тип Promise<Generator<…>> | Он возвращает AsyncGenerator<T, TReturn, TNext>, а не промис от генератора | |
Прогнать асинхронный генератор через toArray/буфер | Теряется потоковость и обратное давление — весь результат снова материализуется |
Значение для собеседований
Асинхронность спрашивают на любом собеседовании уровня middle, и проверяют не знание слов async и await, а модель типов: где язык описывает результат, где — ошибку, и что именно делает await с типом. Кандидат, который говорит «возврат async-функции всегда Promise<T>, await даёт Awaited<T> и разворачивает рекурсивно, а reject нигде не типизирован», сразу отделяется от того, кто отвечает «await ждёт промис».
Что обычно проверяют:
- Разницу между callback-API и Promise-API и то, что ни один из них не типизирует ошибку.
- Почему возврат
async-функции всегдаPromise<T>и что нельзя аннотировать голымT. - Что
awaitразворачивает рекурсивно, а.thenсхлопывает thenable, а не вкладывает. - Зачем нужен
Awaited<T>и чем он лучше ручного условного типа. - Как сохранить кортеж в
Promise.allи чемallSettled/race/anyотличаются по типу результата. - Что такое
async function*, чем он отличается от обычного генератора и как его потреблять.
Типичный неверный ответ: «Promise<T> типизирует и успех, и ошибку — ошибка задаётся вторым аргументом типа». Второго аргумента у Promise нет: язык не знает, чем промис может завершиться неудачей, поэтому catch (e) получает unknown, и надёжный ответ экосистемы — возвращать не бросающий тип-результат Result<T, E> в виде размеченного объединения. Второй классический провал — arr.forEach(async …): правило «возвращаемое значение void-колбэка игнорируется» делает такой код валидным, но forEach не ждёт ни одного промиса, ошибки в них теряются, и цикл завершается раньше самих операций.