Master exception-based and value-based error handling approaches, from traditional try-catch patterns to modern functional programming techniques with monadic structures.
Evolution from exception-based to value-based error handling paradigms
Error handling paradigms: from implicit exception propagation to explicit value-based composition
The core decision in JavaScript error handling is where failure information lives: in an invisible control flow path (exceptions) or in the function’s return type (values). This choice ripples through your entire architecture.
Exception model (try/catch): Failure is a side effect. Functions have two exit paths—return and throw—but the type signature only declares one. The runtime unwinds the stack searching for handlers, which is expensive (~10-100x slower than returns) and makes control flow non-local.
Value model (Result<T, E>): Failure is data. Functions return a discriminated union that forces callers to acknowledge both outcomes. Operations compose via .map() (transform success) and .andThen() (chain fallible operations), with failures automatically bypassing subsequent success handlers—this is Railway Oriented Programming.
The trade-off matrix:
Criterion
try/catch
[data, error]
Result Monad
Type safety
catch receives unknown
Convention-based
Compiler-enforced
Composability
Imperative nesting
Repetitive if (err)
Fluent chaining
Failure forcing
Easy to ignore
Easy to ignore
Linting can enforce
Performance
Stack unwinding
Value return
Value return
Debugging
Native stack traces
May lose stack context
Errors are values
Recommendation: Use neverthrow (v8.x) for new TypeScript code—it balances type safety with ergonomics and has ESLint rules that enforce Result consumption. Reserve try/catch for external boundaries (parsing user input, calling throwing APIs) and convert immediately to Result types.
Error handling shapes architecture. The choice between exceptions and values determines how failure propagates through your system, how composition works, and what guarantees your type system can provide.
JavaScript’s try...catch mechanism, standardized in ECMAScript 3 (ES3, December 1999), treats errors as exceptional events that halt normal execution and transfer control elsewhere. This model served well for decades but carries fundamental limitations: untyped catch blocks, non-local control flow, and easy error swallowing.
The functional alternative—errors as return values—gained momentum with TypeScript’s adoption and the influence of languages like Rust (Result<T, E>) and Haskell (Either a b). Libraries like neverthrow and fp-ts bring these patterns to JavaScript, offering type safety and composability that exceptions cannot provide.
Version context: This article covers ECMAScript 2025 (ES16) for language features and TypeScript 5.x for type system capabilities. TC39 proposal stages are current as of January 2026.
JavaScript’s exception model, rooted in imperative traditions inherited from C++ and Java, treats errors as control flow events that halt execution and transfer to a handler.
Promises (ES2015) and async/await (ES2017) extend exception semantics to asynchronous code, but with critical differences in error propagation.
Promise rejection uses .catch() for error handling:
2 collapsed lines
1
// Promise rejection becomes ThrowCompletion when awaited
2
// or is handled by .catch()
3
fetch("/api/data")
4
.then((response) => response.json())
5
.then((data) =>processData(data))
6
.catch((error) => {
7
console.error("Request failed:", error)
8
return fallbackData
9
})
async/await (ES2017) unifies syntax but changes timing:
2 collapsed lines
1
// await converts Promise rejection to thrown exception
2
// enabling standard try/catch
3
asyncfunctionfetchData() {
4
try {
5
constresponse=awaitfetch("/api/data")
6
constdata=await response.json()
7
returnprocessData(data)
8
} catch (error) {
9
console.error("Request failed:", error)
10
return fallbackData
11
}
12
}
Critical edge cases and failure modes:
Unhandled rejection (silent failure pre-Node 15):
1
asyncfunctionleaky() {
2
fetch("/api/data") // No await, no catch - rejection lost!
3
}
Node.js evolution: Prior to Node.js 15, unhandled rejections only emitted warnings. Node.js 15+ crashes the process by default (--unhandled-rejections=throw).
Promise constructor anti-pattern:
1
// Exceptions in Promise executor are caught and become rejections
2
newPromise((resolve) => {
3
thrownewError("Sync throw") // Becomes rejection, not uncaught exception
4
})
5
6
// BUT: async callbacks inside the executor are NOT caught
7
newPromise((resolve) => {
8
setTimeout(() => {
9
thrownewError("Escapes!") // Uncaught exception
10
}, 0)
11
})
Concurrent await gotcha:
3 collapsed lines
1
// Sequential: Second fetch waits for first
2
// If first fails, second never starts
3
consta=awaitfetch("/a") // Throws here
4
constb=awaitfetch("/b") // Never reached
5
6
// Concurrent: Both start immediately
7
// First rejection wins, second rejection is unhandled!
8
const [a, b] =awaitPromise.all([
9
fetch("/a"), // Rejects
10
fetch("/b"), // Also rejects - this rejection is "lost"
11
])
12
13
// Fix: Use Promise.allSettled for independent operations
Exceptions have fundamental architectural costs that motivate alternatives.
1. Functions have invisible exit paths
A signature like function processData(data): ProcessedData lies—the function has two exit paths (return and throw), but only one is declared. This breaks referential transparency and makes reasoning about code harder.
1
// Type signature promises ProcessedData
2
functionprocessData(data:Input):ProcessedData {
3
if (!data.valid) thrownewError("Invalid") // Hidden exit
4
returntransform(data)
5
}
6
7
// Caller has no compile-time warning
8
constresult=processData(input) // Might throw!
2. Stack unwinding is expensive
When an exception is thrown, the runtime must:
Allocate an Error object and capture the stack trace
Search up the call stack for a catch handler
Unwind each stack frame, running any finally blocks
Performance impact: Throwing is 10-100x slower than returning. In V8, a thrown error costs ~1-2μs vs ~10-50ns for a return. This matters in hot paths.
4 collapsed lines
1
// Benchmark: 1M iterations
2
// Return path: ~15ms
3
// Throw path: ~1500ms (100x slower)
4
5
// Anti-pattern: using exceptions for control flow
6
functionfindItem(arr, predicate) {
7
try {
8
arr.forEach((item) => {
9
if (predicate(item)) throw item // Don't do this
10
})
11
} catch (found) {
12
return found
13
}
14
returnnull
15
}
3. TypeScript’s unknown catch parameter
Per TypeScript 4.4+, catch variables are typed as unknown (previously any). This is correct—JavaScript allows throwing anything—but forces runtime guards:
1
try {
2
riskyOperation()
3
} catch (error) {
4
// error: unknown - must narrow before use
5
if (error instanceofError) {
6
console.log(error.message) // Safe
7
} elseif (typeof error ==="string") {
8
console.log(error) // Also possible
9
} else {
10
console.log("Unknown error type", error)
11
}
12
}
TypeScript history: Prior to 4.4, catch variables were any by default. The useUnknownInCatchVariables compiler option (now default with strict) changed this to unknown, forcing explicit type narrowing.
4. Silent error swallowing
Nothing prevents empty or incomplete catch blocks:
1
try {
2
awaitcriticalOperation()
3
} catch (e) {
4
// "I'll handle this later" - famous last words
5
console.log("Error occurred")
6
// No re-throw, no recovery, error is lost
7
}
5. Error.isError (ES2026)
A new standard feature advancing to Stage 4 addresses error type checking across realms:
1
// Problem: instanceof fails across iframes/realms
2
constiframe= document.createElement("iframe")
3
document.body.appendChild(iframe)
4
constIframeError= iframe.contentWindow.Error
5
constforeignError=newIframeError("from iframe")
6
7
foreignError instanceofError// false! Different Error constructor
8
9
// Solution (ES2026):
10
Error.isError(foreignError) // true - works across realms
The functional alternative treats errors as data, not control flow. Failure becomes part of the return type, making it visible in signatures and enforceable by the type system.
Why this pattern exists: Explicit error acknowledgment at call sites. The err variable is visible, nudging developers to handle it.
Why it’s a leaky abstraction:
No type-level exclusivity: [T | null, Error | null] allows four states: [value, null], [null, error], [null, null], and [value, error]. Only two are valid.
The Result monad (or Either in Haskell/fp-ts terminology) formalizes error-as-value with type-safe guarantees. This pattern originates from ML-family languages and was popularized in Rust.
The core insight: A discriminated union with exactly two variants:
1
typeResult<T, E> =
2
| { ok:true; value:T } // Success
3
| { ok:false; error:E } // Failure
This structure makes invalid states impossible at the type level. You cannot have both value and error, or neither.
Railway Oriented Programming (Scott Wlaschin’s term) visualizes this as two parallel tracks:
fp-ts (v2.16.x, latest as of January 2026) is a complete functional programming toolkit for TypeScript. Its Either<E, A> type implements the Result pattern with Left<E> for failure and Right<A> for success (matching Haskell convention).
Key design choice: Standalone pipeable functions rather than method chaining. Data flows through pipe():
fp-ts-example.ts
4 collapsed lines
1
import { pipe } from"fp-ts/function"
2
import*as E from"fp-ts/Either"
3
// fp-ts uses free functions + pipe() rather than method chaining
4
// This enables tree-shaking and follows FP conventions
Ecosystem evolution: fp-ts is merging with Effect-TS, which represents what would be fp-ts v3. For new projects, consider Effect-TS directly if you want the full FP ecosystem.
TC39 proposals in progress could dramatically improve value-based error handling ergonomics. Understanding their current status helps evaluate when (or if) to adopt polyfills.
Champions: J. S. Choi, James DiGioia, Ron Buckton, Tab Atkins-Bittner
The Pipeline Operator proposal provides left-to-right function composition. TC39 settled on the Hack pipe variant after rejecting F#-style pipes twice.
Syntax: The right-hand side contains an expression with a topic reference (currently %, but not finalized):
pipeline-operator-example.js
5 collapsed lines
1
import*as E from'fp-ts/Either';
2
// Future syntax: Hack pipe with topic reference %
3
// The % placeholder represents the value from the previous step
Why Hack pipes over F# pipes?: F# pipes (|> fn) only work with unary functions. Hack pipes (|> fn(%, arg)) work with any expression, supporting multi-argument functions and method calls.
Current blockers: The topic token (%) is still being bikeshedded. Alternative proposals include ^, #, and @@. This syntactic debate has stalled progress.
Adoption recommendation: Use Babel’s pipeline plugin for experimentation, but don’t rely on it for production—syntax may change.
is operator: Boolean pattern test for conditionals
Why it matters for error handling: Pattern matching provides a native way to exhaustively handle discriminated unions—exactly what Result types are.
Current reality: Stage 1 with limited recent activity. The proposal has remained at this stage since 2018. Don’t hold your breath; use .match() methods in libraries instead.
The Safe Assignment Operator proposal (originally proposal-safe-assignment-operator) is a community-driven attempt to bring Go-style error handling to JavaScript. It is not yet a formal TC39 proposal—it exists in the TC39 discourse forum but hasn’t advanced through official stages.
Proposed syntax:
4 collapsed lines
1
// Proposed ?= operator catches throws and returns tuple
2
// Similar to Go's err, val := operation() pattern
The trajectory is clear: JavaScript error handling is moving from implicit exception propagation toward explicit value-based composition. This shift reflects broader industry trends—Rust’s Result, Go’s tuples, and Haskell’s Either have proven that explicit errors produce more reliable systems.
For new TypeScript projects, neverthrow with ESLint enforcement is the pragmatic default. It provides compile-time safety, lint-time consumption enforcement, and an API familiar to JavaScript developers. Reserve exceptions for true boundaries and unexpected failures.
The TC39 proposals (pipeline, pattern matching) may eventually make this pattern feel native, but their uncertain timelines make waiting impractical. The library ecosystem is mature enough today.
The investment in learning Result types pays dividends in reduced production errors, clearer code review conversations (“did you handle the error case?”), and composable business logic that doesn’t hide failure modes in invisible control flow.
Build resilient, scalable asynchronous task processing systems—from basic in-memory queues to advanced distributed patterns—using Node.js. This article covers the design reasoning behind queue architectures, concurrency control mechanisms, and resilience patterns for production systems.
JavaScript’s string.length returns UTF-16 code units—a 1995 design decision that predates Unicode’s expansion beyond 65,536 characters. This causes '👨👩👧👦'.length to return 11 instead of 1, breaking character counting, truncation, and cursor positioning for any text containing emoji, combining marks, or supplementary plane characters. Understanding the three abstraction layers—grapheme clusters, code points, and code units—is essential for correct Unicode handling.