Асинхронное программирование в TypeScript
TypeScript добавляет статические типы к асинхронным возможностям JavaScript. Благодаря этому асинхронный код становится предсказуемее и проще в сопровождении.
Этот урок идёт от базового async/await к более продвинутым приёмам.
Примечание: Урок предполагает, что вы уже знакомы с основами промисов (Promise) и асинхронного программирования в JavaScript. Если эти идеи для вас новы, сначала изучите асинхронность в JavaScript.
Промисы в TypeScript
TypeScript делает промисы типобезопасными с помощью дженериков.
Promise<T> — это операция, которая завершится позже значением типа T либо завершится неудачей с причиной типа any.
Ключевые моменты:
Promise<T>— обобщённый тип, гдеT— тип разрешённого значения.Promise<void>— для промисов, которые не возвращают значение.Promise<never>— для промисов, которые никогда не разрешаются (встречается редко).
Состояния промиса и поток типов: промис начинается в состоянии pending (ожидание) и переходит либо в fulfilled (выполнен, со значением типа T), либо в rejected (отклонён, с причиной типа any). TypeScript отслеживает эти состояния через систему типов, поэтому вы обрабатываете и успешный случай, и ошибку.
Параметр типа в Promise<T> сообщает TypeScript, чем разрешится промис, а это даёт более строгую проверку типов и лучшую поддержку редактора.
Простой пример с Promise
fetchGreeting возвращает Promise<string>, поэтому внутри .then TypeScript знает, что greeting — это строка. Промис случайным образом разрешается или отклоняется, поэтому в выводе будет либо приветствие в верхнем регистре, либо строка с ошибкой.
fetchGreeting с типом возвращаемого значения Promise<string>. Внутри постройте Promise, который ждёт одну секунду с помощью setTimeout, а затем случайным образом (через Math.random() > 0.5) либо разрешается значением "Привет, TypeScript!", либо отклоняется с Error("Не удалось получить приветствие").
Используйте его через .then — выводя приветствие в верхнем регистре, поскольку TypeScript знает, что это строка, — и через .catch с ошибкой типа Error, выводя "Ошибка:" и её сообщение. Вывод случаен: либо приветствие в верхнем регистре, либо строка с ошибкой.
Вывод: программа на TypeScript и захваченный вывод
// Создаём типизированный Promise, который разрешается строкой
const fetchGreeting = (): Promise<string> => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("Привет, TypeScript!");
} else {
reject(new Error("Не удалось получить приветствие"));
}
}, 1000);
});
};
// Используем Promise с правильным выводом типов
fetchGreeting()
.then((greeting) => {
// TypeScript знает, что 'greeting' — это строка
console.log(greeting.toUpperCase());
})
.catch((error: Error) => {
console.error("Ошибка:", error.message);
});
Async/Await в TypeScript
Синтаксис async/await — более понятный способ работы с промисами. Асинхронный код читается как обычный пошаговый код, а TypeScript продолжает проверять типы.
Основные преимущества async/await:
- Читаемость: последовательный код, за которым проще следить.
- Обработка ошибок:
try/catchподходит и для синхронных, и для асинхронных ошибок. - Отладка: трассировки стека выглядят как у синхронного кода.
- Типобезопасность: полный вывод и проверка типов в TypeScript.
Типы возвращаемых значений async-функций. Каждая async-функция возвращает промис, и объявленный тип возвращаемого значения должен быть обёрнут в него: async function getString(): string { } — это ошибка, а async function getString(): Promise<string> { } — верно.
Примечание: Параметр типа возвращаемого промиса — это тип, который вы пишете после Promise в типе возвращаемого значения.
Простой пример Async/Await
fetchUsers возвращает Promise<User[]>, поэтому после await переменная users имеет тип User[], а обращение к свойствам каждого пользователя проверяется по типам.
User (id, name, email и role, ограниченное значениями 'admin' | 'user' | 'guest') и async function fetchUsers(): Promise<User[]>, которая выводит 'Загрузка пользователей...', ожидает односекундный setTimeout для имитации вызова API, а затем возвращает Alice (admin) и Bob (user).
Напишите async function processUsers(), которая внутри try/catch ожидает fetchUsers(), выводит, сколько пользователей получено, отбирает администраторов и преобразует их в список email, выводит 'Email администраторов:' с этим массивом и повторно выбрасывает любую ошибку после её вывода (с проверкой error instanceof Error). В конце вызовите processUsers() и выведите 'Обработка завершена' при успехе или 'Обработка не удалась:' при неудаче.
Вывод: программа на TypeScript и захваченный вывод
// Определяем типы для ответа нашего API
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
// Функция, которая возвращает Promise с массивом User
async function fetchUsers(): Promise<User[]> {
console.log('Загрузка пользователей...');
// Имитируем вызов API
await new Promise(resolve => setTimeout(resolve, 1000));
return [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' }
];
}
// Асинхронная функция для обработки пользователей
async function processUsers() {
try {
// TypeScript знает, что users имеет тип User[]
const users = await fetchUsers();
console.log(`Получено пользователей: ${users.length}`);
// Типобезопасный доступ к свойствам
const adminEmails = users
.filter(user => user.role === 'admin')
.map(user => user.email);
console.log('Email администраторов:', adminEmails);
return users;
} catch (error) {
if (error instanceof Error) {
console.error('Не удалось обработать пользователей:', error.message);
} else {
console.error('Произошла неизвестная ошибка');
}
throw error; // Пробрасываем дальше, чтобы вызывающий код мог обработать
}
}
// Запускаем асинхронную функцию
processUsers()
.then(users => console.log('Обработка завершена'))
.catch(err => console.error('Обработка не удалась:', err));
Параллельное выполнение с Promise.all
Запускайте несколько асинхронных операций параллельно и дожидайтесь завершения всех. Три товара загружаются одновременно, а их случайные цены суммируются.
Product (id, name, price) и async function fetchProduct(id: number): Promise<Product>, которая выводит Загрузка товара <id>..., ждёт случайное время до одной секунды и возвращает товар с именем Товар <id> и случайной целой ценой меньше 100.
Напишите fetchMultipleProducts(), которая параллельно запускает fetchProduct(1), (2) и (3) через Promise.all, разбирает три результата, суммирует их цены и выводит Итоговая цена: $<сумма с двумя знаками после запятой>; любую ошибку выводите с 'Ошибка при загрузке товаров:'. Вызовите функцию. Итоговая сумма случайна.
Вывод: программа на TypeScript и захваченный вывод
interface Product {
id: number;
name: string;
price: number;
}
async function fetchProduct(id: number): Promise<Product> {
console.log(`Загрузка товара ${id}...`);
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
return { id, name: `Товар ${id}`, price: Math.floor(Math.random() * 100) };
}
async function fetchMultipleProducts() {
try {
// Запускаем все загрузки параллельно
const [product1, product2, product3] = await Promise.all([
fetchProduct(1),
fetchProduct(2),
fetchProduct(3)
]);
const total = [product1, product2, product3]
.reduce((sum, product) => sum + product.price, 0);
console.log(`Итоговая цена: $${total.toFixed(2)}`);
} catch (error) {
console.error('Ошибка при загрузке товаров:', error);
}
}
fetchMultipleProducts();
Типизация колбэков для асинхронных операций
Для старого кода в стиле колбэков TypeScript помогает типизировать параметры колбэка, чтобы вызывающий код точно знал, что он получает.
Пример
Тип FetchCallback говорит, что колбэк получает Error или null, а также необязательные строковые данные. Внутри колбэка TypeScript знает, что data — это строка или undefined.
FetchCallback для колбэка (error: Error | null, data?: string) => void и функцию fetchDataWithCallback(url: string, callback: FetchCallback): void, которая имитирует асинхронную работу с помощью односекундного setTimeout, вызывая колбэк с null и "Данные ответа" (или с Error, если что-то пойдёт не так).
Вызовите её с 'https://api.example.com' и колбэком, который при ошибке выводит 'Ошибка:' и завершается, а иначе выводит данные в верхнем регистре, если они определены.
Вывод: программа на TypeScript и захваченный вывод
// Определяем тип для колбэка
type FetchCallback = (error: Error | null, data?: string) => void;
// Функция, принимающая типизированный колбэк
function fetchDataWithCallback(url: string, callback: FetchCallback): void {
// Имитируем асинхронную операцию
setTimeout(() => {
try {
// Имитируем успешный ответ
callback(null, "Данные ответа");
} catch (error) {
callback(error instanceof Error ? error : new Error('Неизвестная ошибка'));
}
}, 1000);
}
// Использование функции с колбэком
fetchDataWithCallback('https://api.example.com', (error, data) => {
if (error) {
console.error('Ошибка:', error.message);
return;
}
// TypeScript знает, что data — это строка (или undefined)
if (data) {
console.log(data.toUpperCase());
}
});
Комбинирование промисов
TypeScript даёт типизированные версии методов, которые объединяют несколько промисов. Они помогают выполнять операции одновременно и безопасно обрабатывать результаты.
Методы комбинирования промисов:
Promise.all()— ждёт разрешения всех промисов.Promise.race()— возвращает первый завершившийся промис.Promise.allSettled()— ждёт завершения всех (успешного или неудачного).Promise.any()— возвращает первый успешно выполненный промис.
Внимание: Когда вы передаёте в Promise.all промисы разных типов, TypeScript выводит кортеж из отдельных типов результатов. Если выведенный тип недостаточно точен, используйте утверждение типа или сами опишите ожидаемую структуру.
Promise.all — параллельное выполнение
Запускает несколько промисов параллельно и ждёт их все. Завершается сразу с ошибкой, если хотя бы один промис отклонён. Здесь user, posts и stats получают собственные точные типы из результата-кортежа.
fetchUser(id) даёт { id, name: "Пользователь <id>" }, fetchPosts(userId) даёт две записи (Запись 1, Запись 2) с идентификаторами, а fetchStats(userId) даёт { views: 100, likes: 25 }.
Напишите async function loadUserDashboard(userId: number), которая ожидает все три через Promise.all, выводит Пользователь: <name>, Записей: <count> и Лайков: <likes>, возвращает три значения в объекте и выводит 'Не удалось загрузить панель:' перед повторным выбросом ошибки. Вызовите её с идентификатором пользователя 1.
Вывод: программа на TypeScript и захваченный вывод
// Промисы разных типов
const fetchUser = (id: number): Promise<{ id: number; name: string }> =>
Promise.resolve({ id, name: `Пользователь ${id}` });
const fetchPosts = (userId: number): Promise<Array<{ id: number; title: string }>> =>
Promise.resolve([
{ id: 1, title: 'Запись 1' },
{ id: 2, title: 'Запись 2' }
]);
const fetchStats = (userId: number): Promise<{ views: number; likes: number }> =>
Promise.resolve({ views: 100, likes: 25 });
// Запускаем всё параллельно
async function loadUserDashboard(userId: number) {
try {
const [user, posts, stats] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
fetchStats(userId)
]);
// TypeScript знает типы user, posts и stats
console.log(`Пользователь: ${user.name}`);
console.log(`Записей: ${posts.length}`);
console.log(`Лайков: ${stats.likes}`);
return { user, posts, stats };
} catch (error) {
console.error('Не удалось загрузить панель:', error);
throw error;
}
}
// Запускаем с идентификатором пользователя
loadUserDashboard(1);
Promise.race — первый завершившийся
Полезно для тайм-аутов или для получения первого успешного ответа из нескольких источников. fetchWithTimeout состязает промис с отклоняющимся таймером. Функции только определены, поэтому ничего не выводится.
timeout(ms: number): Promise<never>, которая через ms миллисекунд отклоняется с Error("Тайм-аут через <ms> мс"), и обобщённую async function fetchWithTimeout<T>(promise: Promise<T>, timeoutMs = 5000): Promise<T>, которая состязает переданный promise с тайм-аутом и выбрасывает Время ожидания запроса истекло через <ms> мс, если побеждает тайм-аут.
Затем определите (но не вызывайте) fetchUserData(), которая применяет fetchWithTimeout к fetch('https://api.example.com/user/1') с ограничением в 3 секунды, разбирает JSON, а при ошибке выводит 'Ошибка:' с сообщением (приведя к Error) и повторно выбрасывает её. Ничего не выводится, потому что ни одна функция не вызывается.
Вывод: программа на TypeScript и захваченный вывод
// Вспомогательная функция для тайм-аута
const timeout = (ms: number): Promise<never> =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Тайм-аут через ${ms} мс`)), ms)
);
// Имитируем вызов API с тайм-аутом
async function fetchWithTimeout<T>(
promise: Promise<T>,
timeoutMs: number = 5000
): Promise<T> {
return Promise.race([
promise,
timeout(timeoutMs).then(() => {
throw new Error(`Время ожидания запроса истекло через ${timeoutMs} мс`);
}),
]);
}
// Пример использования
async function fetchUserData() {
try {
const response = await fetchWithTimeout(
fetch('https://api.example.com/user/1'),
3000 // тайм-аут 3 секунды
);
const data = await response.json();
return data;
} catch (error) {
console.error('Ошибка:', (error as Error).message);
throw error;
}
}
Promise.allSettled — обработка всех результатов
Используйте это, когда нужно дождаться каждого промиса, независимо от того, успешен он или нет. Каждый результат проверяется предикатом типа, прежде чем его засчитают как успешный или неудачный. Часть запросов случайно завершается неудачей, поэтому количества меняются.
fetchData(id: number), которая выбрасывает Error("Не удалось получить данные для ID <id>"), когда Math.random() > 0.7, и иначе возвращает { id, data: "Данные для <id>" }.
Напишите async function processBatch(ids: number[]), которая преобразует каждый id в fetchData(id), обёрнутый так, чтобы он разрешался значением { status: 'fulfilled' as const, value } или { status: 'rejected' as const, reason }, ожидает Promise.allSettled для них, разделяет успешные и неудачные результаты фильтрами с предикатами типа (PromiseFulfilledResult / PromiseRejectedResult), выводит Успешно обработано: <n> и Не удалось: <n> и возвращает оба списка. Вызовите её с идентификаторами от 1 до 5. Количества случайны.
Вывод: программа на TypeScript и захваченный вывод
// Имитируем несколько вызовов API с разными результатами
const fetchData = async (id: number) => {
// Случайным образом «роняем» часть запросов
if (Math.random() > 0.7) {
throw new Error(`Не удалось получить данные для ID ${id}`);
}
return { id, data: `Данные для ${id}` };
};
// Обрабатываем несколько элементов с индивидуальной обработкой ошибок
async function processBatch(ids: number[]) {
const promises = ids.map(id =>
fetchData(id)
.then(value => ({ status: 'fulfilled' as const, value }))
.catch(reason => ({ status: 'rejected' as const, reason }))
);
// Ждём завершения всех
const results = await Promise.allSettled(promises);
// Обрабатываем результаты
const successful = results
.filter((result): result is PromiseFulfilledResult<{ status: 'fulfilled', value: any }> =>
result.status === 'fulfilled' &&
result.value.status === 'fulfilled'
)
.map(r => r.value.value);
const failed = results
.filter((result): result is PromiseRejectedResult |
PromiseFulfilledResult<{ status: 'rejected', reason: any }> => {
if (result.status === 'rejected') return true;
return result.value.status === 'rejected';
});
console.log(`Успешно обработано: ${successful.length}`);
console.log(`Не удалось: ${failed.length}`);
return { successful, failed };
}
// Обрабатываем пакет идентификаторов
processBatch([1, 2, 3, 4, 5]);
Обработка ошибок в асинхронном коде
TypeScript даёт хорошие инструменты для типобезопасной обработки ошибок в асинхронном коде. Вот некоторые приёмы и рекомендации.
Стратегии обработки ошибок:
- Блоки try/catch: для обработки ошибок в async/await.
- Границы ошибок: для компонентов React.
- Типы результата: функциональный подход со значениями успеха или неудачи.
- Наследование классов ошибок: для ошибок конкретной предметной области.
Приёмы обработки ошибок, которые стоит рассмотреть:
- Границы ошибок в React для обработки на уровне компонента.
- Объекты результата вместо выбрасывания исключений для ожидаемых случаев.
- Глобальный обработчик ошибок для неперехваченных исключений.
- Логирование ошибок, чтобы фиксировать и сообщать об ошибках.
Пользовательские классы ошибок
Создавайте типы ошибок для своей предметной области, чтобы обрабатывать их точнее. AppError — базовый класс, а NetworkError, ValidationError и NotFoundError несут каждый свой код. Локальная mockFetch заменяет fetch, чтобы пример работал без сети.
AppError extends Error, конструктор которого принимает message, public readonly code: string и необязательное public readonly details?: unknown, задаёт this.name равным имени класса и, если возможно, захватывает трассировку стека; затем NetworkError (код 'NETWORK_ERROR'), ValidationError (код 'VALIDATION_ERROR', с public readonly field) и NotFoundError (код 'NOT_FOUND', сообщение <resource> с ID <id> не найден).
Напишите async function fetchUserData(userId: string): Promise<{ id: string; name: string }>, которая ожидает локальную mockFetch (Promise, разрешающийся поддельным ответом { ok, status, json() }, чтобы сеть не использовалась), выбрасывает NotFoundError при 404, NetworkError при 5xx, обычную Error при других неуспешных статусах и ValidationError('name', ...), когда в JSON нет name; непредвиденные ошибки оборачиваются в AppError с кодом 'UNEXPECTED_ERROR'. Добавьте displayUserProfile(userId), которая выводит 'Профиль пользователя:' вместе с пользователем либо выводит через console.error свой префикс для каждого класса ошибки (Проблема с сетью:, Проверка не пройдена:, Не найдено:, Непредвиденная ошибка:), и вызовите её с '123'.
Вывод: программа на TypeScript и захваченный вывод
// Базовый класс ошибки для нашего приложения
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly details?: unknown
) {
super(message);
this.name = this.constructor.name;
(Error as any).captureStackTrace?.(this, this.constructor);
}
}
// Конкретные типы ошибок
class NetworkError extends AppError {
constructor(message: string, details?: unknown) {
super(message, 'NETWORK_ERROR', details);
}
}
class ValidationError extends AppError {
constructor(
public readonly field: string,
message: string
) {
super(message, 'VALIDATION_ERROR', { field });
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string | number) {
super(
`${resource} с ID ${id} не найден`,
'NOT_FOUND',
{ resource, id }
);
}
}
// Локальная замена fetch, чтобы пример никогда не обращался к сети
const mockFetch = (url: string): Promise<{ ok: boolean; status: number; json: () => Promise<any> }> =>
Promise.resolve({
ok: true,
status: 200,
json: async () => ({ id: url.split('/').pop(), name: 'Alice' })
});
// Пример использования
async function fetchUserData(userId: string): Promise<{ id: string; name: string }> {
try {
// Имитируем вызов API
const response = await mockFetch(`/api/users/${userId}`);
if (!response.ok) {
if (response.status === 404) {
throw new NotFoundError('Пользователь', userId);
} else if (response.status >= 500) {
throw new NetworkError('Ошибка сервера', { status: response.status });
} else {
throw new Error(`Ошибка HTTP! статус: ${response.status}`);
}
}
const data = await response.json();
// Проверяем данные ответа
if (!data.name) {
throw new ValidationError('name', 'Поле name обязательно');
}
return data;
} catch (error) {
if (error instanceof AppError) {
// Это уже одна из наших пользовательских ошибок
throw error;
}
// Оборачиваем непредвиденные ошибки
throw new AppError(
'Не удалось получить данные пользователя',
'UNEXPECTED_ERROR',
{ cause: error }
);
}
}
// Обработка ошибок в приложении
async function displayUserProfile(userId: string) {
try {
const user = await fetchUserData(userId);
console.log('Профиль пользователя:', user);
} catch (error) {
if (error instanceof NetworkError) {
console.error('Проблема с сетью:', error.message);
// Показать интерфейс повторной попытки
} else if (error instanceof ValidationError) {
console.error('Проверка не пройдена:', error.message);
// Подсветить некорректное поле
} else if (error instanceof NotFoundError) {
console.error('Не найдено:', error.message);
// Показать страницу 404
} else {
console.error('Непредвиденная ошибка:', error);
// Показать общее сообщение об ошибке
}
}
}
// Запускаем с примерными данными
displayUserProfile('123');
Асинхронная итерация в TypeScript
TypeScript поддерживает асинхронные итераторы и асинхронные генераторы с полной типизацией. Цикл for await достаёт значения по одному, как только они готовы.
Пример
generateNumbers типизирована как AsyncGenerator<number, void, unknown>, поэтому внутри цикла for await num — это число. Здесь функции только определены, поэтому ничего не выводится.
generateNumbers() с типом AsyncGenerator<number, void, unknown>, который выдаёт значения от 0 до 4, ожидая одну секунду (через setTimeout внутри Promise с await) перед каждым значением.
Добавьте async function consumeNumbers(), которая перебирает генератор с помощью for await и выводит каждое число, умноженное на два. Только определите функции; ничего не вызывается, поэтому ничего не выводится.
Вывод: программа на TypeScript и захваченный вывод
// Асинхронная функция-генератор
async function* generateNumbers(): AsyncGenerator<number, void, unknown> {
let i = 0;
while (i < 5) {
// Имитируем асинхронную операцию
await new Promise(resolve => setTimeout(resolve, 1000));
yield i++;
}
}
// Использование асинхронного генератора
async function consumeNumbers() {
for await (const num of generateNumbers()) {
// TypeScript знает, что num — это число
console.log(num * 2);
}
}