Async/Await и reject: почему это боль для новичков?
Вы когда-нибудь писали async функцию, ждали ответа от сервера, а потом ловили UnhandledPromiseRejection? Знакомо? Это одна из самых частых проблем, с которой сталкиваются разработчики, переходящие на async/await в TypeScript.
На Stack Overflow вопрос "How to reject in async/await syntax?" набрал тысячи просмотров. И не зря. На первый взгляд кажется, что достаточно просто вернуть Promise.reject() — и дело в шляпе. Но в реальных проектах всё сложнее: типы, цепочки вызовов, потеря контекста ошибок.
В этом руководстве мы разберём, как правильно отклонять промисы в async функциях TypeScript, научимся отличать throw от return Promise.reject() и посмотрим на практические примеры из боевых проектов.
Основы: как работает reject в async/await
Прежде чем нырять в дебри, давайте вспомним матчасть. Любая async функция в TypeScript возвращает Promise. Если внутри неё происходит throw или возвращается Promise.reject(), промис переходит в состояние rejected.
// Простейший пример reject в async/await
async function fetchUser(id: number): Promise<User> {
if (id <= 0) {
throw new Error('Invalid user ID');
}
// ...логика получения пользователя
}
Здесь throw автоматически превращается в reject. Но что если мы хотим вернуть уже готовый отклонённый промис?
throw vs return Promise.reject()
В TypeScript оба подхода работают, но есть нюансы:
- throw — более идиоматичный способ. Он останавливает выполнение функции и передаёт управление в ближайший
catch. - return Promise.reject() — явно возвращает отклонённый промис. Полезно, когда нужно передать ошибку из другой функции.
// Разница между throw и return Promise.reject()
async function processOrder(orderId: string): Promise<OrderStatus> {
// Вариант 1: throw
if (!orderId) {
throw new Error('Order ID is required');
}
// Вариант 2: return Promise.reject
if (orderId.startsWith('test_')) {
return Promise.reject(new Error('Test orders are not allowed'));
}
return { status: 'processing' };
}
Важно: TypeScript не всегда корректно выводит типы при return Promise.reject(). Если функция ожидает Promise<T>, а вы возвращаете Promise.reject(), может возникнуть ошибка типизации. В таких случаях лучше использовать throw.
Типичные ошибки при reject в async функциях
Даже опытные разработчики на TypeScript иногда допускают классические ошибки. Вот топ-3:
1. Забыли обработать reject в вызывающем коде
async function main() {
// Плохо: нет try/catch
const user = await fetchUser(-1);
console.log(user); // UnhandledPromiseRejection!
}
// Хорошо:
async function mainSafe() {
try {
const user = await fetchUser(-1);
console.log(user);
} catch (error) {
console.error('Failed to fetch user:', error);
}
}
2. Неправильная типизация ошибок
В TypeScript catch блок по умолчанию ловит unknown. Это правильно, но иногда разработчики пытаются обращаться к error.message без проверки.
try {
await riskyOperation();
} catch (error) {
// Ошибка: error — unknown
console.log(error.message); // ❌ Property 'message' does not exist on type 'unknown'
// Правильно:
if (error instanceof Error) {
console.log(error.message);
} else {
console.log('Unknown error:', error);
}
}
3. Смешивание reject и resolve в одной функции
Иногда разработчики пытаются одновременно и отклонить, и выполнить промис:
// Антипаттерн
async function getData(): Promise<Data> {
try {
const result = await apiCall();
return result; // resolve
} catch (error) {
return Promise.reject(error); // reject
}
}
// Лучше:
async function getDataBetter(): Promise<Data> {
return await apiCall();
}
Продвинутые техники: reject с кастомными ошибками
В реальных проектах просто new Error('message') часто недостаточно. Нужно передавать код ошибки, данные, стек вызовов. В TypeScript для этого создают кастомные классы ошибок.
// Кастомная ошибка с дополнительными полями
class ApiError extends Error {
constructor(
public statusCode: number,
public message: string,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'ApiError';
}
}
async function fetchData(url: string): Promise<ResponseData> {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError(
response.status,
`HTTP Error: ${response.statusText}`,
{ url, timestamp: Date.now() }
);
}
return response.json();
}
Такой подход позволяет в catch блоке точно определить тип ошибки и обработать её специфично:
try {
const data = await fetchData('/api/users');
} catch (error) {
if (error instanceof ApiError) {
if (error.statusCode === 404) {
showNotFoundPage();
} else if (error.statusCode === 500) {
showServerError(error.details);
}
} else {
showGenericError();
}
}
Асинхронные цепочки: как не потерять reject
Когда у вас несколько async вызовов подряд, легко потерять reject где-то посередине. Рассмотрим типичный сценарий:
async function processPayment(paymentId: string): Promise<PaymentResult> {
// Шаг 1: проверка платежа
const payment = await getPayment(paymentId);
if (!payment) {
throw new Error('Payment not found');
}
// Шаг 2: списание средств
const chargeResult = await chargeCard(payment);
if (!chargeResult.success) {
// Здесь reject пойдёт вверх по цепочке
throw new PaymentError('Charge failed', chargeResult.errorCode);
}
// Шаг 3: отправка уведомления
await sendNotification(payment.userId);
return { status: 'completed', transactionId: chargeResult.id };
}
Обратите внимание: если chargeCard выбросит ошибку, выполнение сразу перейдёт в catch вызывающего кода. sendNotification не выполнится. Это правильное поведение, но иногда нужно выполнить очистку ресурсов.
finally: гарантированное выполнение после reject
async function processWithCleanup(orderId: string): Promise<void> {
const dbConnection = await openConnection();
try {
const order = await dbConnection.findOrder(orderId);
if (!order) {
throw new Error('Order not found');
}
await processOrder(order);
} catch (error) {
console.error('Processing failed:', error);
throw error; // П