The Death of Try/Catch: A Better Way to Handle Errors in TypeScript
Modern Error Handling Patterns for Safer, Cleaner, and More Predictable TypeScript Applications
Senior Developer

If you write JavaScript or TypeScript, your asynchronous code probably looks like a massive nesting doll of try/catch blocks.
async function fetchUserData(userId: string) {
try {
const user = await db.users.find(userId);
try {
const posts = await api.fetchPosts(user.id);
return { user, posts };
} catch (apiError) {
console.error("Failed to fetch posts");
throw new Error("Post fetching failed");
}
} catch (dbError) {
console.error("Failed to fetch user");
throw new Error("Database failed");
}
}
This pattern is deeply flawed for three reasons:
Scope leakage: Variables defined inside
trycannot be accessed outside of it without declaring them asletbeforehand.Loss of Type Safety: In TypeScript, errors caught in a
catch(error)block are always typed asunknownorany. You have no idea what actually failed.The "Throw everything" antipattern: We use errors for normal control flow, making it impossible to know if a function will actually return a value or blow up the call stack.
Let's look at a much better, highly practical way to handle errors in TypeScript.
The Golang Approach: Errors as Values
In languages like Go and Rust, errors are not thrown into the void; they are returned as standard values. We can easily adopt this pattern in TypeScript.
Instead of returning Data and throwing an Error, we return a tuple: [Error | null, Data | null].
Creating the Wrapper
First, let's create a tiny utility function that wraps any Promise:
// utils/catchAsync.ts
type SafeReturn<T, E = Error> =
| [E, null]
| [null, T];
export async function catchAsync<T, E = Error>(
promise: Promise<T>
): Promise<SafeReturn<T, E>> {
try {
const data = await promise;
return [null, data];
} catch (error) {
return [error as E, null];
}
}Refactoring our Code
Now, let's rewrite our nested try/catch disaster using our new catchAsync utility.
import { catchAsync } from './utils/catchAsync';
async function fetchUserData(userId: string) {
// 1. Fetch User
const [userError, user] = await catchAsync(db.users.find(userId));
if (userError) {
console.error("Database failed", userError);
return null; // Handle it gracefully, right here.
}
// 2. Fetch Posts (we know 'user' is safely defined here)
const [postError, posts] = await catchAsync(api.fetchPosts(user.id));
if (postError) {
console.error("API failed", postError);
// Maybe we just return the user with empty posts if the API is down
return { user, posts: [] };
}
// 3. Success
return { user, posts };
}Why is this vastly superior?
Straight-line Code: We've completely eliminated nesting. The code reads cleanly from top-to-bottom.
Const correctness: We can use
constforuserandpostsbecause they are instantiated in the main scope, not trapped inside atryblock.Forced Error Handling: Because you must destructure the error
[error, data], TypeScript practically forces you to acknowledge that an error might happen before using the data.Type Narrowing: When you check
if (error), TypeScript automatically narrows the type ofdatato exactlyTin the rest of the function block. No moreunknown.
Advanced: The Result Pattern (NeverThrow)
If you want to take this to the absolute limit of functional type safety, you can use the Result pattern (heavily inspired by Rust), often implemented via libraries like neverthrow.
npm install neverthrowUsing neverthrow, functions explicitly return a Result object that is either ok or err.
import { Result, ok, err } from 'neverthrow';
// Function explicitly declares it returns a User OR a DatabaseError
function getUser(id: string): Result<User, DatabaseError> {
const user = db.find(id);
if (!user) {
return err(new DatabaseError("User not found"));
}
return ok(user);
}
const result = getUser("123");
if (result.isErr()) {
// TypeScript knows result.error is exactly 'DatabaseError'
console.log(result.error.message);
} else {
// TypeScript knows result.value is exactly 'User'
console.log(result.value.name);
}Conclusion
Stop using try/catch for expected application logic (like an API returning a 404, or a database query finding no records). Reserve throw for truly exceptional, catastrophic failures (like running out of memory or infinite loops).
By treating errors as standard return values, your code becomes highly predictable, far easier to test, and perfectly typed.
Comments (0)
Login to post a comment.