Safer Error Handling in TypeScript with the Result Type
Error handling in JavaScript and TypeScript has traditionally relied on exceptions using throw and try/catch. While this mechanism is simple, it comes with a hidden cost: you often don’t know what kind of errors a function can throw—especially when it calls other functions deep in a call chain.
As a result, many bugs arise not from code logic itself, but from missing or incorrect error handling. In large systems, this leads to fragile code and runtime crashes that are hard to trace.
This article explores a better alternative: the Result<T, E> pattern, a functional-style approach that makes all possible errors explicit, even across nested calls. This approach is inspired by languages like Rust.
Traditional Error Handling in JavaScript & TypeScript
Let’s look at a simple example of traditional error handling:
function riskyDivide(a: number, b: number): number {
if (b === 0) throw new Error('Divide by zero');
return a / b;
}
function computeResult(): number {
return riskyDivide(10, 0); // Could throw!
}
Here’s the problem:
- There’s no type information telling us that riskyDivide can throw.
- There’s no way for the compiler to enforce that you catch the error.
- If riskyDivide is nested several layers deep, you might miss it entirely.
Why “just throwing” is brittle
Aspect | Consequence |
---|---|
Implicitness | The function’s type signature doesn’t reveal which errors might be thrown. Callers must consult documentation—or the source—to know what to catch. |
Unchecked at compile-time | TypeScript’s compiler ignores throw paths. A forgotten try/catch silently becomes a potential runtime crash. |
Nested surprise | Even if you don’t throw inside a function, a helper two levels down might. Troubleshooting becomes whack-a-mole. |
Control-flow opacity | throw abruptly interrupts normal control flow, making code harder to reason about and test—especially in asynchronous chains. |
Enter the Result<T, E> Pattern
The Result type is a discriminated union that returns either a successful result or a typed error:
type Result<T, E extends Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Benefits:
- Errors are part of the type system.
- You are forced to handle them explicitly.
- Error types are tracked across nested calls.
- Promotes total functions: functions that always return a value (success or failure), never throw.
Declaring Typed Errors
TypeScript uses structural typing, meaning two error classes with the same shape (e.g., just extending Error) are indistinguishable at compile time. Without adding a unique property like _tag, TypeScript treats different error types (e.g. DivideByZeroError vs. ThirdPartyError) as interchangeable.
By including a discriminant field like _tag, you enable precise type narrowing and exhaustive switch handling—just like discriminated unions elsewhere in TypeScript.
class DivideByZeroError extends Error {
readonly _tag = 'DivideByZeroError';
constructor() {
super('Cannot divide by zero');
}
}
class ThirdPartyError extends Error {
readonly _tag = 'ThirdPartyError';
constructor() {
super('Third-party failure');
}
}
Writing Functions with Result<T, E>
With the Result<T, E> pattern, functions no longer throw errors—they return them explicitly as part of the return type. This makes failure cases visible and forces the caller to handle them safely.
Here’s a simple example:
function safeDivide(a: number, b: number): Result<number, DivideByZeroError> {
if (b === 0) return { ok: false, error: new DivideByZeroError() };
return { ok: true, value: a / b };
}
This function guarantees that it will never throw. Instead, it either returns a successful result ({ ok: true, value }) or a known error (DivideByZeroError)—which is clearly indicated in the type signature.
Handling the Result
const result = safeDivide(10, 0);
if (result.ok) {
console.log('Result:', result.value);
} else {
switch (result.error._tag) {
case DivideByZeroError._tag:
console.error('Cannot divide by zero.');
break;
}
}
This approach helps eliminate unexpected exceptions and enables safe, predictable error handling across your codebase.
Nesting: Propagating Errors Up the Chain
Suppose we use a risky third-party call within our function:
function riskyThirdPartyCall(): Result<number, ThirdPartyError> {
if (Math.random() < 0.5) {
return { ok: false, error: new ThirdPartyError() };
}
return { ok: true, value: 42 };
}
We can now compose these:
function compute(): Result<number, DivideByZeroError | ThirdPartyError> {
const division = safeDivide(100, 5);
if (!division.ok) return division;
const thirdParty = riskyThirdPartyCall();
if (!thirdParty.ok) return thirdParty;
return { ok: true, value: division.value + thirdParty.value };
}
You see all possible errors just by looking at the return type:
Result<number, DivideByZeroError | ThirdPartyError>
This is impossible to express with throw.
Pattern Matching and Exhaustive Error Handling
const outcome = compute();
if (outcome.ok) {
console.log('Success:', outcome.value);
} else {
switch (outcome.error._tag) {
case 'DivideByZeroError':
case 'ThirdPartyError':
console.error(outcome.error.message);
break;
default:
const _exhaustive: never = outcome.error;
throw new Error(`Unhandled error: ${_exhaustive}`);
}
}
The default case with never will trigger a compiler error if a new error type is added but not handled.
Comparison with throw
Feature | throw | Result<T, E> |
---|---|---|
Compile-time error awareness | ❌ | ✅ |
Nested error propagation | ❌ | ✅ |
Type-safe error handling | ❌ | ✅ |
Requires explicit handling | ❌ | ✅ |
Composability | ❌ | ✅ |
Stack trace availability | ✅ | ✅ (still uses Error ) |
Tradeoffs
- More boilerplate: Requires manually wrapping results and checking .ok.
- Verbosity: Deep nesting without helpers can get unwieldy.
- Learning curve: Teams need to adopt the mindset consistently.
But for critical systems—such as backend services, CLI tools, or libraries—the safety is worth it.
Conclusion
By replacing exceptions with explicit Result<T, E> types, TypeScript developers gain:
- Full visibility into all possible errors
- Safer refactoring and function composition
- Exhaustive handling enforced by the compiler
While it introduces a bit of verbosity, it pays off in correctness, testability, and maintainability—especially in larger codebases.
The next time you’re tempted to write throw new Error(…), consider returning a Result instead. Your future self (and your teammates) will thank you.
Full code example to play with
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
class DivideByZeroError extends Error {
readonly _tag = 'DivideByZeroError';
constructor() {
super('Cannot divide by zero');
}
}
class ThirdPartyError extends Error {
readonly _tag = 'ThirdPartyError';
constructor() {
super('Third-party failure');
}
}
function riskyThirdPartyCall(): Result<number, ThirdPartyError> {
if (Math.random() < 0.5) {
return { ok: false, error: new ThirdPartyError() };
}
return { ok: true, value: 42 };
}
function safeDivide(a: number, b: number): Result<number, DivideByZeroError> {
if (b === 0) return { ok: false, error: new DivideByZeroError() };
return { ok: true, value: a / b };
}
function compute(): Result<number, DivideByZeroError | ThirdPartyError> {
const division = safeDivide(100, 5);
if (!division.ok) return division;
const thirdParty = riskyThirdPartyCall();
if (!thirdParty.ok) return thirdParty;
return { ok: true, value: division.value + thirdParty.value };
}
function main() {
const outcome = compute();
if (outcome.ok) {
console.log('Success:', outcome.value);
} else {
switch (outcome.error._tag) {
case 'DivideByZeroError':
case 'ThirdPartyError':
console.error(outcome.error.message);
break;
default:
const _exhaustive: never = outcome.error;
throw new Error(`Unhandled error: ${_exhaustive}`);
}
}
}
main()