Типизация 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.

Проверьте себя
1. Какой тип выведет TypeScript для функции: async function load(): number { return 5; } — и что произойдёт при компиляции?
AТип будет number, компиляция пройдёт успешно
BОшибка компиляции: async-функция должна возвращать Promise<number>, а не number
CТип автоматически станет any
DОшибка только в рантайме, компиляция пройдёт
2. Какой тип имеет переменная error в блоке catch (error) { ... } в современном TypeScript, и почему?
AError — потому что почти всегда выбрасывают именно Error
Bany — потому что ошибки не типизируются
Cunknown — потому что в JavaScript можно выбросить (throw) значение любого типа, не только Error
Dnever — потому что catch недостижим при строгой типизации