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

TypeScript расширяет асинхронные возможности JavaScript за счёт статической типизации, делая ваш асинхронный код более предсказуемым и удобным для поддержки.

В этом руководстве рассмотрены все аспекты — от базовых конструкций async/await до продвинутых паттернов.

Предполагается, что читатель обладает базовыми знаниями о промисах (Promise) в JavaScript и принципах асинхронного программирования.


Промисы в TypeScript

TypeScript дополняет промисы JavaScript типовой безопасностью за счёт использования обобщенных типов (дженериков).

Promise<T> представляет асинхронную операцию, которая завершится либо значением типа T, либо ошибкой типа any.

Ключевые моменты:

  • Promise<T> — обобщенный тип, где T — тип разрешённого (успешного) значения;
  • Promise<void> — для промисов, не возвращающих значения;
  • Promise<never> — для промисов, которые никогда не разрешаются (редкий случай).

Пример базового промиса


// Создаём типизированный промис, который разрешается строкой
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);
  });
};

// Используем промис с выводом типов
fetchGreeting()
  .then((greeting) => {
    // TypeScript знает, что 'greeting' — строка
    console.log(greeting.toUpperCase());
  })
  .catch((error: Error) => {
    console.error("Ошибка:", error.message);
  });

Состояния промиса и поток типов

Поток состояний промиса:

  • ожидание → выполнено (со значением типа T) — успешный случай;
  • ожидание → отклонено (с причиной типа any) — случай ошибки.

TypeScript отслеживает эти состояния через систему типов, гарантируя корректную обработку как успешных, так и ошибочных сценариев.

Параметр типа в Promise<T> сообщает TypeScript, к какому типу будет приведено разрешение промиса, что обеспечивает лучшую проверку типов и поддержку в IDE.


Async/await в TypeScript

Синтаксис async/await в TypeScript предлагает более удобный способ работы с промисами: асинхронный код выглядит и ведёт себя почти как синхронный, при этом сохраняется типовая безопасность.

Основные преимущества async/await:

  • Читаемость: последовательный код проще воспринимать;
  • Обработка ошибок: можно использовать try/catch для синхронных и асинхронных ошибок;
  • Отладка: проще отлаживать благодаря стековым трассировкам, похожим на синхронные;
  • Типовая безопасность: полный вывод и проверка типов TypeScript.

Базовый пример использования async/await


// Определяем типы для ответа API
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

// Функция, возвращающая промис массива пользователей
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('Почта админа:', 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));

Типы возвращаемых значений асинхронных функций

Все асинхронные функции в TypeScript возвращают промис.

Тип возвращаемого значения автоматически оборачивается в Promise:


async function getString(): string { } // Ошибка: должен возвращать Promise
async function getString(): Promise<string> { } // Верно

Параллельное выполнение с Promise.all

Запускаем несколько асинхронных операций параллельно и ждём завершения всех:


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 возвращают Promise.

Параметр типа промиса соответствует типу, указанному после ключевого слова Promise.


Типизация обратных вызовов для асинхронных операций

Для традиционного асинхронного кода на основе обратных вызовов 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 — параллельное выполнение

Запускает несколько промисов параллельно и ждёт завершения всех.

Немедленно прерывается, если хотя бы один промис отклоняется.


// Промисы различных типов
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 — первая завершившаяся операция

Полезно для реализации таймаутов или получения первого успешного результата из множества источников.


// Вспомогательная функция для таймаута
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 — обработать все результаты

Используется, когда нужно дождаться завершения всех промисов независимо от успеха или отказа.


// Имитация нескольких 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]);

Предупреждение: Когда вы используете Promise.all() с массивом промисов разных типов, TypeScript выводит результирующий тип как массив объединения всех возможных типов.

Для более точной типизации вам может понадобиться вручную объявить ожидаемый тип или использовать аннотации типов.


Обработка ошибок в асинхронном коде

TypeScript предоставляет мощные инструменты для безопасной обработки ошибок в асинхронном коде.

Давайте рассмотрим различные подходы и лучшие практики.

Стратегии обработки ошибок:

  • Блоки try/catch: для обработки ошибок в async/await.
  • Границы ошибок: для изолированной обработки ошибок в компонентах React.
  • Типы результатов: функциональный подход с успехами и провалами.
  • Подклассы ошибок: для специализированных доменно-зависимых ошибок.

Пользовательские классы ошибок

Создаем специализированные типы ошибок для качественного контроля ошибок:


// Базовый класс ошибок для нашего приложения
class AppError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly details?: unknown
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.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 });
  }
}

// Пример использования
async function fetchUserData(userId: string): Promise<{ id: string; name: string }> {
  try {
    // Имитация API-запроса
    const response = await fetch(`/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', 'Имя обязательно');
    }

    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');

Паттерны обработки ошибок

Используйте следующие паттерны для надежной обработки ошибок:

  1. Границы ошибок в React для обработки ошибок на уровне компонентов.
  2. Объекты результатов вместо генерации исключений для предсказуемых ситуаций.
  3. Глобальный обработчик ошибок для перехвата незахваченных исключений.
  4. Логирование ошибок для фиксации и отслеживания ошибок.

Асинхронные итерации в TypeScript

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);
  }
}