
Stop Catching Everything: Clean Errors with Result Pattern
Stop wrapping every async function in messy try-catch blocks. Learn how the Result pattern brings type safety and cleaner error handling to TypeScript.
If you look through a typical TypeScript backend or frontend codebase, you will almost certainly spot try-catch blocks sprinkled over every single asynchronous function. While throwing exceptions seems convenient when you first learn language basics, relying on exceptions for expected domain errors makes application flow invisible, completely bypasses TypeScript's compile-time type safety, and turns codebases into fragile debugging nightmares.
In this article, we will examine why traditional try-catch exception handling breaks down in modern TypeScript applications and how adopting the Result Pattern—popularized by functional programming and languages like Rust—can help you write explicit, type-safe, and self-documenting code.
The Problem with Exceptions for Expected Domain Failures
In standard TypeScript code, an async function signature often looks completely clean and innocent on the surface:
async function fetchUserProfile(userId: string): Promise<UserProfile> {
// Might throw UserNotFoundError, DatabaseTimeoutError, or UnauthorizedError
}
Looking purely at the function declaration, any developer calling fetchUserProfile would assume it always returns a UserProfile. Nothing in the type system warns the caller that this function might throw a UserNotFoundError or an UnauthorizedError.
To handle potential failures safely, developers end up wrapping every single function call inside a try-catch block:
try {
const user = await fetchUserProfile("usr_123");
renderUserDashboard(user);
} catch (error: unknown) {
// Is error a UserNotFoundError? Network error? SyntaxError?
// TypeScript forces error to be 'unknown' or 'any'
logger.error(error);
}
This pattern creates three major pain points:
- Invisible Failures: The function signature lies about potential outcomes. You have to read the internal implementation of every helper function to know what exceptions it might throw.
- Loss of Type Safety: Inside a
catchblock, TypeScript typeserrorasunknownorany. You are forced to write awkwardinstanceofchecks or manual type guards to guess what went wrong. - Flow Control Pollution: Exception handling interrupts normal execution flow and unwinds the call stack across multiple layers, making it hard to reason about where errors are actually caught and handled.
What is the Result Pattern?

The core philosophy behind the Result pattern is simple: an expected error is a valid return value, not an uncaught emergency exception.
Instead of throwing an error into the unknown stack space, a function returns a generic container object called Result<T, E>. This container represents either a successful outcome containing data (T) or an explicit failure containing a domain error (E).
Here is how you can define a lightweight, zero-dependency Result type in TypeScript:
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E> = Ok<T> | Err<E>;
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
Because Result is a TypeScript discriminated union keyed on the boolean property ok, checking if (result.ok) automatically narrows the type in subsequent lines without requiring any manual type casting.
Refactoring in Practice: Before and After
Let's look at a realistic e-commerce checkout flow to see the difference in clarity and safety.
Before: Fragile Exception Catching
async function checkoutOrder(userId: string, cartId: string): Promise<OrderSummary> {
try {
const user = await getUser(userId);
const cart = await getCart(cartId);
const payment = await chargeCreditCard(user, cart.totalAmount);
const order = await createOrderRecord(user.id, cart.items, payment.id);
return order;
} catch (error) {
// We have no compile-time idea which step failed
// If chargeCreditCard failed vs getCart failed, handling logic is lumped together
throw new Error(`Checkout failed: ${error}`);
}
}
After: Type-Safe Result Pipeline
Now let's rewrite the same business logic using explicit domain errors and the Result pattern:
type CheckoutError = UserNotFoundError | EmptyCartError | PaymentDeclinedError | DatabaseError;
async function checkoutOrder(
userId: string,
cartId: string
): Promise<Result<OrderSummary, CheckoutError>> {
const userRes = await getUser(userId);
if (!userRes.ok) {
return err(userRes.error); // Early return with explicit UserNotFoundError
}
const cartRes = await getCart(cartId);
if (!cartRes.ok) {
return err(cartRes.error); // Early return with explicit EmptyCartError
}
const paymentRes = await chargeCreditCard(userRes.value, cartRes.value.totalAmount);
if (!paymentRes.ok) {
return err(paymentRes.error); // Early return with explicit PaymentDeclinedError
}
const orderRes = await createOrderRecord(
userRes.value.id,
cartRes.value.items,
paymentRes.value.id
);
if (!orderRes.ok) {
return err(orderRes.error);
}
return ok(orderRes.value);
}
Notice what happens at the caller level when calling checkoutOrder:
const result = await checkoutOrder("usr_101", "cart_99");
if (!result.ok) {
// TypeScript exhaustively knows result.error can only be one of CheckoutError variants
switch (result.error.kind) {
case "PaymentDeclined":
return showNotification("Payment was declined. Please try another card.");
case "EmptyCart":
return showNotification("Your cart is empty.");
default:
return showNotification("An error occurred. Please try again.");
}
}
// TypeScript knows result.value is strictly OrderSummary here!
renderOrderReceipt(result.value);
The difference is night and day. The function signature fully communicates all possible failure outcomes, compiler autocomplete works seamlessly, and caller code cannot accidentally forget to handle failures.
When Should You Still Use Try-Catch?
Adopting the Result pattern does not mean try-catch is completely banned from your project. Instead, it creates a clear boundary:
- Use Result Pattern for Expected Business Domain Errors: Invalid input validation, user not found, insufficient funds, expired tokens, or item out of stock. These are expected states of your domain logic.
- Use Try-Catch for Unexpected System Crashes: Out of memory errors, hardware failures, database connection dropped unexpectedly, or broken third-party library internals.
When consuming external third-party SDKs or legacy libraries that throw native JavaScript exceptions, wrap them at your infrastructure boundary with a simple helper utility:
async function toResult<T>(promise: Promise<T>): Promise<Result<T, Error>> {
try {
const value = await promise;
return ok(value);
} catch (error) {
return err(error instanceof Error ? error : new Error(String(error)));
}
}
This ensures that raw exceptions are contained at your application edge and converted into manageable Result objects before touching your core business code.
Key Takeaways for Pragmatic Developers
Shifting from exception-driven code to the Result pattern transforms how your team designs APIs and handles failure edge cases:
- Self-Documenting Signatures: Function types become honest contracts that reflect both success and domain failure types.
- Exhaustive Handling: TypeScript's type checker prevents developers from silently ignoring potential failure paths.
- Clean Stack Traces: Eliminates unnecessary stack unwinding overhead for everyday business errors.
Start small in your next feature module by returning { ok: true, value } | { ok: false, error } from your domain services. Your team and future self will thank you when debugging complex workflows!
written by
Nguyên Tech
Responses
Loading comments…