Типизация Promise и async/await
Вопрос-крючок: «Вот функция для загрузки пользователя. Какой у неё тип возврата, и что выведет TypeScript, если вы попробуете написать функцию без явного типа Promise?»
interface User {
id: number;
name: string;
}
async function getUser(id: number) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data;
}
// наведите курсор на getUser в редакторе...
const user = getUser(1);Вывод (тип, который покажет редактор):
function getUser(id: number): Promise<any>
const user: Promise<any>Многие ждут, что раз в интерфейсе есть User, TypeScript сам «догадается» подставить его в тип результата. Не догадается. Компилятор честно вывел Promise<any> — потому что response.json() по своей природе не знает, что именно лежит в ответе сервера, и возвращает any. Это первая и самая важная ловушка темы: асинхронность сама по себе не защищает от потери типов.
Что происходит и почему. Когда функция помечена словом async, она ВСЕГДА возвращает Promise — даже если внутри неё написано return 5, снаружи вы получите Promise<number>, а не просто number. Это не какая-то условность TypeScript, а свойство самого JavaScript: async-функция оборачивает своё возвращаемое значение в промис автоматически. TypeScript честно отражает это в типах.
Но вот что важно: TypeScript выводит тип Promise ИЗ ТОГО, что реально возвращает тело функции, а не из того, что вы «имели в виду». Метод fetch(...).json() в браузерном API типизирован как Promise<any> — потому что тело HTTP-ответа может быть чем угодно, компилятор не может заглянуть внутрь сети и узнать структуру JSON. Раз внутри функции появился any, он «протекает» дальше — и весь возврат функции превращается в Promise<any>, теряя всякую защиту типов.
Чтобы починить это, тип результата указывают явно — прямо в сигнатуре функции, через двоеточие после списка параметров:
async function getUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data as User; // явно говорим: доверяю, что тут придёт User
}
const user = getUser(1);
// user: Promise<User>
async function useUser() {
const u = await user;
console.log(u.name); // u: User, автодополнение работает, опечатка в u.nam покажет ошибку
}Теперь если где-то в коде вы попробуете обратиться к несуществующему полю, например u.age вместо u.name, компилятор сразу подсветит ошибку — раньше он бы промолчал, потому что any отключает все проверки.
Как это работает под капотом. Ключевое слово await внутри async-функции «разворачивает» Promise: если у вас есть Promise<User>, то после await вы получаете значение типа User, а не Promise<User>. TypeScript умеет вкладывать это разворачивание рекурсивно — если бы функция вернула Promise<Promise<User>> (что вообще-то в JS невозможно, промисы автоматически «расплющиваются» в один уровень), await всё равно дал бы просто User. Это соответствует поведению настоящего JavaScript: цепочка промисов внутри промиса всегда схлопывается в один уровень ожидания.
Отдельная тема — типизация ошибок в асинхронном коде. Здесь есть техническая деталь, которая ставит в тупик на собеседовании почти всех:
async function safeGetUser(id: number): Promise<User | null> {
try {
return await getUser(id);
} catch (error) {
// error: unknown — а НЕ Error!
console.log(error.message); // ОШИБКА компиляции
return null;
}
}В современном TypeScript переменная в блоке catch имеет тип unknown, а не Error и не any. Это сделано намеренно: в JavaScript можно выбросить (throw) абсолютно что угодно — не только объект Error, но и строку, число, объект без единого метода. TypeScript не может гарантировать, что в catch попадёт именно Error, поэтому честно говорит «это неизвестность, сначала проверь, прежде чем использовать».
Написать catch (error: Error) и явно задать тип НЕЛЬЗЯ — TypeScript такой синтаксис не разрешает, потому что это было бы ложью компилятору. Правильный путь — сузить тип (type narrowing) внутри самого catch:
catch (error) {
if (error instanceof Error) {
console.log(error.message); // теперь безопасно, error: Error
} else {
console.log('Что-то пошло не так:', error);
}
return null;
}Частые ошибки на собеседовании. Первая — забывают, что async function всегда возвращает Promise, и пишут в сигнатуре просто User вместо Promise<User>, получая ошибку компиляции. Вторая — думают, что раз есть интерфейс User, TypeScript сам подставит его в результат fetch().json(), хотя реально нужно явное приведение или отдельная проверка структуры данных. Третья, самая частая — пытаются указать конкретный тип ошибки в catch (catch (e: Error) или даже catch (e: MyCustomError)) и удивляются ошибке компилятора; в TS 4.4+ по умолчанию там всегда unknown, и тип нужно сужать вручную через instanceof.
Итоги-шпаргалка. async-функция всегда возвращает Promise<T>, даже если T явно не написан — тип указывается через двоеточие после параметров: async function f(): Promise<T>. await разворачивает Promise<T> в T. Тип ошибки в catch по умолчанию — unknown, не Error и не any; чтобы получить доступ к .message или другим полям, нужно сузить тип через instanceof Error. Явно задать тип переменной catch нельзя — это правило языка, а не забывчивость авторов TypeScript.