Async/Await and reject: why is it a pain for beginners?
\nHave you ever written an async function, waited for a response from the server, and then caught an UnhandledPromiseRejection? Sound familiar? This is one of the most common problems developers face when transitioning to async/await in TypeScript.
On Stack Overflow, the question "How to reject in async/await syntax?" has garnered thousands of views. And for good reason. At first glance, it seems enough to simply return Promise.reject() — and you're done. But in real projects, things are more complex: types, call chains, loss of error context.
In this guide, we'll break down how to properly reject promises in TypeScript async functions, learn to distinguish throw from return Promise.reject(), and look at practical examples from real-world projects.
Basics: how reject works in async/await
\nBefore diving into the details, let's recall the fundamentals. Any async function in TypeScript returns a Promise. If a throw occurs inside it, or a Promise.reject() is returned, the promise transitions to the rejected state.
// Simplest example of reject in async/await\nasync function fetchUser(id: number): Promise<User> {\n if (id <= 0) {\n throw new Error('Invalid user ID');\n }\n // ...logic to fetch the user\n}\n\nHere, throw automatically turns into a reject. But what if we want to return an already rejected promise?
throw vs return Promise.reject()
\nIn TypeScript, both approaches work, but there are nuances:
\n\n- \n
- throw — a more idiomatic way. It stops the function execution and passes control to the nearest
catch. \n - return Promise.reject() — explicitly returns a rejected promise. Useful when you need to pass an error from another function. \n
// Difference between throw and return Promise.reject()\nasync function processOrder(orderId: string): Promise<OrderStatus> {\n // Option 1: throw\n if (!orderId) {\n throw new Error('Order ID is required');\n }\n \n // Option 2: return Promise.reject\n if (orderId.startsWith('test_')) {\n return Promise.reject(new Error('Test orders are not allowed'));\n }\n \n return { status: 'processing' };\n}\n\nImportant: TypeScript does not always correctly infer types with return Promise.reject(). If a function expects Promise<T> and you return Promise.reject(), a typing error may occur. In such cases, it's better to use throw.
Common mistakes when rejecting in async functions
\nEven experienced TypeScript developers sometimes make classic mistakes. Here are the top 3:
\n\n1. Forgot to handle reject in the calling code
\nasync function main() {\n // Bad: no try/catch\n const user = await fetchUser(-1);\n console.log(user); // UnhandledPromiseRejection!\n}\n\n// Good:\nasync function mainSafe() {\n try {\n const user = await fetchUser(-1);\n console.log(user);\n } catch (error) {\n console.error('Failed to fetch user:', error);\n }\n}\n\n2. Incorrect error typing
\nIn TypeScript, the catch block by default catches unknown. This is correct, but sometimes developers try to access error.message without checking.
try {\n await riskyOperation();\n} catch (error) {\n // Error: error — unknown\n console.log(error.message); // ❌ Property 'message' does not exist on type 'unknown'\n \n // Correct:\n if (error instanceof Error) {\n console.log(error.message);\n } else {\n console.log