Перейти к основному содержимому

Асинхронное программирование в TypeScript

Разработчику Архитектору

Дальше: Дженерики · TypeScript и Node.js · Обработка ошибок · Справочник — расширенные типы


Механизм async в TypeScript тот же, что в JavaScript: Promise, event loop, микрозадачи. TypeScript добавляет контракты — что лежит внутри Promise, какие поля у ответа API, как описать состояния UI так, чтобы компилятор видел все ветки.

Маршрут: Типы и типизацияфункцииasyncTypeScript и React / TypeScript и Node.js.

Сначала: асинхронность в JavaScript — очереди, Promise, async/await.


Что даёт типизация async-кода

Без типовС TypeScript
res.json()anyЯвный User или unknown + парсер
Забыли поле в ответеОшибка при обращении к user.email
Смешали loading и dataDiscriminated union по status
catch (e) — неясно что в ecatch (error: unknown) + сужение

Проверка не отменяет сетевые сбои: типы описывают ожидания, валидацию внешних JSON — рекомендации (Zod и др.).


Promise<T>

Promise — обобщённый тип: параметр T — тип успешного результата.

function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

function fetchNumber(): Promise<number> {
return Promise.resolve(42);
}

Разбор:

  • Promise<void> — успех без полезного значения (аналог пустого return).
  • Promise<number> — при await получите number.
  • Отклонение (reject) типом не параметризуется в стандартной библиотеке — в catch работают с unknown.

async и await

async-функция всегда возвращает Promise:

async function loadLabel(): Promise<string> {
await delay(100);
return "готово";
}

// Эквивалентно: Promise<string>
const p: Promise<string> = loadLabel();

Разбор:

  • return "готово" внутри async оборачивается в Promise.resolve.
  • await разворачивает Promise<T> в T внутри async-функции.
  • Если await получает не-Promise, значение оборачивается (как в JS) — см. 21.md.

Типизация fetch и JSON

Ответ сети не гарантирует форму данных. Опасный путь — слепое приведение:

// Плохо: компилятор верит, сервер может прислать что угодно
const user = (await res.json()) as User;

Учебный контракт и безопасный разбор:

interface User {
id: string;
name: string;
}

function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const o = value as Record<string, unknown>;
return typeof o.id === "string" && typeof o.name === "string";
}

async function loadUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const raw: unknown = await res.json();
if (!isUser(raw)) {
throw new Error("Неверный формат ответа");
}
return raw;
}

Разбор:

  • unknown после json() честно отражает, что структура ответа ещё не проверена.
  • Type predicate value is User сужает тип после проверки.
  • В production часто используют Zod / valibot6.md.

Состояние загрузки как discriminated union

Один из самых практичных паттернов UI и сервисов — объединение состояний с полем-дискриминантом (подробнее в 10.md):

type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };

type User = { id: string; name: string };
type UserLoadState = LoadState<User>;

Функция загрузки описывает весь жизненный цикл запроса (idle, loading, success, error), а не только тип User:

async function fetchUserState(id: string): Promise<UserLoadState> {
try {
const user = await loadUser(id);
return { status: "success", data: user };
} catch (error: unknown) {
return { status: "error", message: toMessage(error) };
}
}

Отрисовка с исчерпывающим switch:

function renderUserState(state: UserLoadState): string {
switch (state.status) {
case "idle":
return 'Нажмите "Загрузить"';
case "loading":
return "Загрузка…";
case "success":
return state.data.name;
case "error":
return state.message;
default: {
const _exhaustive: never = state;
return _exhaustive;
}
}
}

Разбор:

  • В ветке success поле data доступно — TS знает это по status.
  • never в default ловит забытый вариант union при расширении типа.
  • Отдельные флаги isLoading + data? часто дают невозможные комбинации; union этого не допускает.

Параллельные запросы: Promise.all

type Dashboard = {
user: User;
notifications: { id: string; text: string }[];
};

async function loadDashboard(userId: string): Promise<Dashboard> {
const [user, notifications] = await Promise.all([
loadUser(userId),
loadNotifications(userId),
]);
return { user, notifications };
}

async function loadNotifications(
userId: string,
): Promise<{ id: string; text: string }[]> {
const res = await fetch(`/api/notifications/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw: unknown = await res.json();
if (!Array.isArray(raw)) throw new Error("Ожидался массив");
return raw as { id: string; text: string }[];
}

Разбор:

  • Promise.all возвращает кортеж типов [A, B, …] при фиксированном списке промисов.
  • Если один промис rejected — весь all rejected (для частичного успеха — Promise.allSettled).

Тип Promise.allSettled:

const results = await Promise.allSettled([
loadUser("1"),
loadUser("2"),
]);

for (const r of results) {
if (r.status === "fulfilled") {
console.log(r.value.name);
} else {
console.log(r.reason);
}
}

Promise.race и таймаут

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут")), ms);
});
return Promise.race([promise, timeout]);
}

Разбор:

  • Promise<never> у таймаута — ветка только с reject.
  • Дженерик T сохраняется у успешного результата — см. 24.md.

Обработка ошибок: unknown в catch

function toMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Неизвестная ошибка";
}

async function safeLoad(id: string): Promise<User | null> {
try {
return await loadUser(id);
} catch (error: unknown) {
console.error(toMessage(error));
return null;
}
}

Разбор:

  • В modern TS в catch лучше unknown, не any.
  • instanceof Error — частый случай; произвольные throw "строка" тоже встречаются в JS.

Не используйте catch (e: any) как shortcut — в strict-проекте это обнуляет пользу типов.


Retry для нестабильных API

async function withRetry<T>(
task: () => Promise<T>,
retries = 2,
): Promise<T> {
let lastError: unknown;

for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
return await task();
} catch (error: unknown) {
lastError = error;
}
}

throw new Error(toMessage(lastError));
}

// Использование
const user = await withRetry(() => loadUser("u-1"), 3);

Разбор:

  • taskфабрика промиса: каждая попытка создаёт новый запрос.
  • Тип T пробрасывается сквозь retry без потери информации.

Тип Awaited<T>

Встроенный utility извлекает тип результата Promise (в том числе вложенного):

type UserPromise = Promise<User>;
type ResolvedUser = Awaited<UserPromise>; // User

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

Полезно в обёртках над fetch и в generic-хелперах — Справочник — утилитарные типы.


Таймеры: setTimeout в браузере и Node

Многие ожидают, что setTimeout всегда возвращает число. В браузере так и есть; в Node.js — объект Timeout.

const id = setTimeout(() => {}, 1000);

// В браузере: typeof id === "number"
// В Node.js: typeof id === "object"

Из-за этого универсальный fullstack-код с let timerId: number ломается в одной из сред:

// let timerId: number;
// timerId = setTimeout(fn, 1000); // ошибка в Node или в DOM typings

let timerId: ReturnType<typeof setTimeout>;

timerId = setTimeout(() => {}, 1000);
clearTimeout(timerId);

Разбор:

  • ReturnType<typeof setTimeout> подставляет фактический тип из текущих @types (DOM vs Node) — дженерики §infer.
  • Для полей класса, общих утилит и библиотек не хардкодьте number без проверки целевой среды.
  • Отмена: clearTimeout(timerId) в обеих средах; в Node для setInterval — тот же приём.

Async в callback-API

Иногда библиотека ожидает (err, data) => void. Типизируйте обёртку:

function readFileUtf8(path: string): Promise<string> {
return new Promise((resolve, reject) => {
// псевдокод Node callback API
fakeRead(path, (err: Error | null, data?: string) => {
if (err) reject(err);
else resolve(data ?? "");
});
});
}

declare function fakeRead(
path: string,
cb: (err: Error | null, data?: string) => void,
): void;

В Node 20+ предпочитайте fs/promises с уже типизированными Promise22.md.


Связь с event loop

TypeScript не меняет порядок микрозадач и макрозадач. Типы не помогут, если вы ожидаете синхронный код после await вне async-функции.

Концепция JSНапоминание для TS
async → всегда Promiseаннотируйте возврат Promise<T> явно при экспорте API
await → микрозадачапорядок логов как в 21.md
Unhandled rejectionобрабатывайте catch или .catch()

Частые ошибки

ОшибкаПричинаЧто делать
as User на json()ложная уверенностьunknown + guard или Zod
async без awaitлишний Promise в стекеубрать async или добавить реальное ожидание
Забыли awaitполучили Promise вместо Tвключить ESLint @typescript-eslint/no-floating-promises
isLoading + data отдельнонесогласованные состоянияLoadState union
catch (e: any)отключена проверкаunknown + toMessage
let id: number = setTimeout(...)разные типы DOM и NodeReturnType<typeof setTimeout>
Типы в порядке, runtime падаетсеть/логикатесты, валидация, 27.md

Практика

  1. Опишите LoadState<Product> и функцию loadProduct(id) с ветками success/error.
  2. Напишите render(state) с default: never для exhaustive check.
  3. Реализуйте loadDashboard через Promise.all с двумя разными endpoint.
  4. Оберните загрузку в withRetry (3 попытки) и залогируйте toMessage при провале.
  5. Замените as User на isUser predicate и убедитесь, что ложный JSON не проходит типизацию.

Смежные статьи