If you spend enough time reading TypeScript articles, you will notice two loud, opposing camps:

  1. "Never throw—use Result<T, E> for everything!"
  2. "Just throw and 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: throw is safely captured inside async functions, async function* generators, and RxJS Observables. In unsafe constructs (Sync Generators, Signals, Timers, Event Listeners), catch everything inside or never throw.
  • In async functions: Prefer throw new Error(...) over return Promise.reject(...) to avoid extra Promise allocations and forgotten-return bugs.
  • Libraries vs. Apps: Libraries should return typed errors (Result<T, E> or T | SubError) for expected failures, while applications can safely throw into 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 catches throw and 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 never throw inside 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 or effect() 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 outer try/catch around setTimeout or addEventListener will never catch a throw inside the callback. Always wrap the callback body itself in try/catch.
  • .forEach(async () => ...): .forEach() ignores returned Promises. If an async callback throws inside .forEach(), it creates a floating UnhandledPromiseRejection. Use for...of (sequential) or await Promise.all(items.map(...)) (parallel) inside a try/catch instead.

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 throw in a library—return Result<T, E> or T | SubError.
  • Programmer Bugs (Invariant Violations): Impossible states caused by a developer wiring up your API incorrectly ( e.g., calling db.query() before db.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

  1. Know your construct: throw safely inside async functions, async function*, and RxJS Observables. In unsafe constructs (Sync Generators, Signals, Timers, Event Listeners), catch all or never throw.
  2. In async functions, prefer throw over return Promise.reject() to avoid extra Promise allocations and forgotten-return bugs.
  3. Catch external exceptions at the edge and translate them into typed errors (Result or T | SubError).
  4. Libraries return errors; apps throw into boundaries: Keep library contracts explicit in TypeScript types, and save throw for 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!