If you spend enough time reading TypeScript articles, you will notice two loud, opposing camps:
- "Never
throw—useResult<T, E>for everything!" - "Just
throwand let your Error Boundary or middleware catch it!"
The truth is that both camps are right—depending on the runtime construct you are inside and whether you are writing a
library or an application.
TL;DR
- Know your construct:
throwis safely captured insideasyncfunctions,async function*generators, and RxJS Observables. In unsafe constructs (Sync Generators, Signals, Timers, Event Listeners), catch everything inside or neverthrow.- In
asyncfunctions: Preferthrow new Error(...)overreturn Promise.reject(...)to avoid extra Promise allocations and forgotten-returnbugs.- Libraries vs. Apps: Libraries should return typed errors (
Result<T, E>orT | SubError) for expected failures, while applications can safelythrowinto centralized boundaries they control.
1. The Golden Rule: Capturing vs. Non-Capturing Constructs
Before deciding whether to throw, you need to know how your runtime construct handles an exception. Every construct in
TypeScript falls into one of two buckets:
- Safe Constructs (Captures
throw): The construct has a built-in error or rejection channel that catchesthrowand routes it safely to a handler. - Unsafe Constructs (Does NOT Capture
throw): There is no error channel. Your rule here must be either catch everything inside the boundary, or neverthrowinside it at all.
Master Cheat Sheet
| Construct | Captures throw Safely? | Where the Error Goes |
|---|---|---|
async / Promise | Yes | Promise rejection branch (.catch() / await in try/catch) |
Async Generator (async function*) | Yes | Rejected .next() Promise (try/catch around for await...of) |
| RxJS Observable | Yes | Stream error channel (catchError / subscribe({ error })) |
Promise.allSettled() | Yes | Captured per item as { status: "rejected", reason } |
Sync Generator (function*) | No | Crashes .next() synchronously |
Signals (computed()) | No | Cached and crashes synchronously whenever read |
Event Listeners / setTimeout | No | Uncaught global error / UnhandledPromiseRejection |
.forEach(async () => ...) | No | Floating rejected Promises (bypasses outer try/catch) |
2. Where throw Is Safe (Part of the Contract)
async Functions: Prefer throw Over return Promise.reject()
When rejecting inside an async function, prefer throw new Error(...) over return Promise.reject(...). Because an
async function already wraps its return value in a Promise, returning Promise.reject(...) creates a second,
unnecessary Promise object that JavaScript just has to unwrap and forward to the outer one. Using throw rejects the
outer Promise directly, avoiding extra memory allocation, preventing bugs from a forgotten return keyword, and giving
TypeScript clearer control-flow analysis so it knows code after the error is unreachable.
// ❌ Creates an unnecessary extra Promise—and forgetting `return` causes a floating rejection bug! async function getUserBad(id: string): Promise<User> { if (!id) { return Promise.reject(new Error("User ID is required")); } return db.findUser(id); } // ✅ Rejects the outer Promise directly and narrows control flow cleanly async function getUserGood(id: string): Promise<User> { if (!id) { throw new Error("User ID is required"); } return db.findUser(id); } Async Generators (async function*)
In an async generator, every .next() call returns a Promise. Because of that, throw is automatically captured as a
rejected Promise for that iteration step—meaning a standard try/catch around for await...of catches it safely, and
any finally block inside the generator still runs for cleanup.
async function* streamPages(totalPages: number): AsyncGenerator<string, void, unknown> { try { for (let page = 1; page <= totalPages; page++) { if (page === 3) { // Safe: captured as a rejected Promise for the current iteration step throw new Error("Failed to fetch page 3"); } yield `Data from page ${page}`; } } finally { console.log("Cleaning up async stream connection..."); } } async function runStream() { try { for await (const data of streamPages(5)) { console.log(data); } } catch (err) { console.error("Caught async generator error:", err instanceof Error ? err.message : err); } } RxJS Observables
In reactive streams like RxJS, throw is completely safe inside operators because the Observable contract defines
three explicit channels: next, error, and complete. Throwing inside map or switchMap routes the error straight
to catchError.
import {of, map, catchError} from "rxjs"; of("valid-json", "{invalid-json}") .pipe( map((raw) => JSON.parse(raw)), // Safe to throw—routed to the error channel catchError((err: unknown) => { const message = err instanceof Error ? err.message : "Unknown parse error"; console.warn("Stream caught error safely:", message); return of({fallback: true}); // Recover and keep stream alive }) ) .subscribe((data) => console.log("Received:", data)); 3. Where Never to Leave throw Uncaught (Unsafe Constructs)
Sync Generators (function*)
Unlike an async generator, a sync generator has no rejection branch. When it hits throw, .next() does not return
{ value, done }—it immediately crashes synchronously at the call site with zero TypeScript warning.
function* readLines(lines: string[]): Generator<string, void, unknown> { for (const line of lines) { if (line === "CORRUPT") { throw new Error("Encountered corrupt line"); // No reject branch! } yield line.trim(); } } const syncGen = readLines(["CORRUPT"]); syncGen.next(); // 💥 Crashes synchronously immediately (no .catch() exists) Signals (computed())
Signals (in Angular, Solid, Preact, or the TC39 Signals proposal) represent synchronous reactive state, not async
streams. They have no .catch() or error channel:
- If a
computed()signal throws, it caches the error and re-throws synchronously every time a component oreffect()reads it. - Instead of throwing inside a signal, catch expected failures and return a typed state union:
// ❌ UNSAFE: Reading `parsedUser()` later crashes synchronously wherever it is read const parsedUser = computed(() => JSON.parse(rawUserSignal())); // ✅ SAFE: Model the error state explicitly in TypeScript type UserState = | { status: "ready"; user: User } | { status: "error"; error: string }; const safeUserState = computed<UserState>(() => { try { return {status: "ready", user: JSON.parse(rawUserSignal())}; } catch (err) { return { status: "error", error: err instanceof Error ? err.message : "Invalid JSON", }; } }); Event Listeners, Timers, and .forEach(async ...)
- Event Listeners &
setTimeout: Callbacks run on a separate tick of the event loop with an empty call stack. An outertry/catcharoundsetTimeoutoraddEventListenerwill never catch athrowinside the callback. Always wrap the callback body itself intry/catch. -
.forEach(async () => ...):.forEach()ignores returned Promises. If anasynccallback throws inside.forEach(), it creates a floatingUnhandledPromiseRejection. Usefor...of(sequential) orawait Promise.all(items.map(...))(parallel) inside atry/catchinstead.
4. TypeScript’s Blind Spot: Invisible Exceptions
If you come from a language like Java, one of the most frustrating parts of TypeScript is that function signatures
don't tell you if a function can throw.
In Java, checked exceptions (throws IOException) force the compiler to warn callers to either handle the error or
declare that they re-throw it. In TypeScript, a function that crashes half the time looks identical to one that never
fails:
class ValidationError extends Error { constructor(message: string) { super(message); this.name = "ValidationError"; } } /** * Parses a raw JSON string into a User object. * @throws {ValidationError} When the user ID is missing. * @throws {SyntaxError} When the input is invalid JSON. */ function parseUserSafe(json: string): User { const data = JSON.parse(json); if (!data.id) { throw new ValidationError("Missing user ID"); } return data; } // At the call site: catch what you expect, re-throw everything else try { const user = parseUserSafe(rawInput); } catch (err) { if (err instanceof ValidationError) { console.warn("Invalid user payload:", err.message); } else { throw err; // Re-throw unexpected bugs } } Capture External Exceptions Immediately
Whenever you call untrusted or external APIs (JSON.parse(), fetch(), third-party SDKs), catch immediately at the
boundary and translate raw exceptions into your own typed domain error (SubError) or Result. Once errors are
well-typed inside your app, let them bubble up to your centralized recovery boundary rather than wrapping every single
function in noisy try/catch blocks.
5. Library vs. Application Code: Who Owns the Boundary?
This is where the "never throw" vs. "just throw" debate finally makes sense. The rule depends on which side of the
boundary you are writing:
In Library Code: Strong Type Contracts, Never Surprise throw
When you publish a library or shared internal package, the consumer only sees your .d.ts types. Because TypeScript
cannot encode throws in a function signature, throwing for operational errors (invalid input, expired tokens,
network failures) sets a hidden trap for the caller.
Instead, encode expected failures directly in the return type:
| Pattern | Signature Example | Best For | Main Trade-off |
|---|---|---|---|
Result<T, E> | `{ ok: true, value: T } \ | { ok: false, error: E }` | Generic libraries & complex domain logic |
| **`T \ | SubError`** | `User \ | AuthError` |
| Go Tuple | `[T, null] \ | [null, E]` | Flat async/await flows without try/catch |
**Option (`T \ | null`)** | `User \ | null` |
Effect (Effect<A, E>) | Effect<User, AuthError> | Large enterprise apps wanting Java-style typed errors | High learning curve; replaces standard async/await |
Example: Returning T | SubError in a Library
class AuthError extends Error { constructor(message: string, public readonly code: "EXPIRED" | "INVALID") { super(message); this.name = "AuthError"; } } // Library contract: explicitly tells TypeScript callers this can fail with AuthError function verifyToken(token: string): User | AuthError { if (!token) { return new AuthError("Token is missing", "INVALID"); // Returned, NOT thrown! } return {id: "123", name: "Alice"}; } // Application consumer: TypeScript forces the check before using `user` const result = verifyToken(rawToken); if (result instanceof AuthError) { throw result; // App developer chooses whether to recover locally or throw to ErrorBoundary! } console.log(result.name); // ✅ Narrowed to `User` Operational Errors vs. Programmer Bugs (Invariant Violations)
In library design, separate operational errors from programmer bugs:
- Operational Errors (Expected Failures): Invalid user input, expired tokens, or network timeouts. Never
throwin a library—returnResult<T, E>orT | SubError. - Programmer Bugs (Invariant Violations): Impossible states caused by a developer wiring up your API incorrectly ( e.g., calling
db.query()beforedb.connect()). Throwing loudly (in both dev and prod) surfaces the setup bug immediately and keeps your happy-path return types clean.
Special Case: React Hooks (Why Throwing Traps Consumers)
React Hooks introduce a unique catch: you cannot wrap a hook call in a try/catch block without violating the Rules
of Hooks. If a library hook throws when <AuthProvider> is missing, a shared component (like a <Navbar /> rendered on
both public and authenticated pages) cannot recover inline.
The cleanest library pattern is to expose both a non-throwing base hook and a strict convenience hook:
// 1. Safe base hook: never throws, letting shared components recover inline export function useOptionalAuth(): AuthContextValue | null { return useContext(AuthContext); } // 2. Strict convenience hook: throws for protected routes where auth is guaranteed export function useAuth(): AuthContextValue { const ctx = useOptionalAuth(); if (!ctx) { throw new Error("useAuth must be used within an <AuthProvider>"); } return ctx; } In Application Code: You Own the Boundaries, So throw Is Safe
In an application, you control both the call site and the top-level recovery boundary—such as a React
ErrorBoundary, Next.js error.tsx, or global Express/Fastify error middleware. Letting async service calls or
strict hooks throw directly into boundaries you own eliminates if (!res.ok) boilerplate across dozens of
intermediate components.
6. Bonus: What’s Next? TC39 throw Expressions
Today in JavaScript and TypeScript, throw is a statement, not an expression—meaning you cannot use it directly
inside nullish coalescing (??), ternary operators, or concise arrow functions without writing a helper function.
The TC39 throw Expressions proposal (currently at Stage 2) turns throw into an expression that evaluates to
TypeScript's never type. Because string | never simplifies purely to string in TypeScript, null checks become
clean one-liners:
// Today's TypeScript workaround: a `never`-returning helper function function raise(msg: string): never { throw new Error(msg); } const apiKey = process.env.API_KEY ?? raise("Missing API_KEY"); // Narrowed to `string` // Future (Stage 2 Proposal): Native `throw` expression const apiKeyFuture = process.env.API_KEY ?? throw new Error("Missing API_KEY"); Summary: 4 Rules to Take With You
- Know your construct:
throwsafely insideasyncfunctions,async function*, and RxJS Observables. In unsafe constructs (Sync Generators, Signals, Timers, Event Listeners), catch all or never throw. - In
asyncfunctions, preferthrowoverreturn Promise.reject()to avoid extra Promise allocations and forgotten-returnbugs. - Catch external exceptions at the edge and translate them into typed errors (
ResultorT | SubError). - Libraries return errors; apps throw into boundaries: Keep library contracts explicit in TypeScript types, and save
throwfor application boundaries you control.
Where do you draw the line between throw and Result types in your TypeScript projects? Drop your take in the
comments!