
Stop Throwing Errors: Safe Handling in TypeScript
Stop relying on unpredictable try/catch blocks. Learn how to use the Result pattern and discriminated unions in TypeScript for safer error handling.
Have you ever called a function in a TypeScript codebase and wondered, "Will this blow up in my face?" Unless you dive deep into the implementation details or rely on external documentation, you simply can't be sure. TypeScript's robust type system gives us incredible confidence about what a function returns when everything goes right. But when things go wrong? It's notoriously silent.
The standard way to handle errors in JavaScript and TypeScript is by using the throw keyword alongside try/catch blocks. While this mechanism is built into the language, overusing it for everyday business logic can lead to unpredictable, hard-to-maintain code. Today, I want to share a more pragmatic, type-safe approach inspired by languages like Go and Rust: treating errors as standard return values.
The Hidden Costs of try/catch
Let’s look at a typical scenario. You have a function that fetches a user profile from a database.
async function getUserProfile(userId: string): Promise<UserProfile> {
const user = await db.users.find(userId);
if (!user) {
throw new UserNotFoundError(`User ${userId} not found`);
}
return user;
}
At first glance, this looks completely normal. The signature explicitly states it returns a Promise<UserProfile>. But there is a hidden side effect: it can throw a UserNotFoundError. The function signature does not warn the caller about this possibility. If the developer calling this function forgets to wrap it in a try/catch block, the application might crash at runtime.
Worse still, when you do remember to catch the error, TypeScript forces you to deal with an unknown type.
try {
const profile = await getUserProfile("123");
renderProfile(profile);
} catch (error) {
// 'error' is typed as 'unknown' in modern TypeScript
if (error instanceof UserNotFoundError) {
showUIError(error.message);
} else {
reportToCrashlytics(error);
}
}
Inside the catch block, all type safety is thrown out the window. You have to write boilerplate instanceof checks or type guards just to safely read the error message. As your application grows, these try/catch blocks start nesting, making the control flow incredibly difficult to follow.
A Better Way: Errors as Values

What if we stopped treating expected business errors as fatal exceptions? In languages like Go or Rust, you don't "throw" a "user not found" error. Instead, you return it as a normal value alongside the success data.
We can bring this exact same predictability to TypeScript using a feature called Discriminated Unions. By defining a standard Result type, we can force the type system to acknowledge both the success state and the failure state.
Let's define a simple, generic Result type:
export type Success<T> = {
ok: true;
value: T;
};
export type Failure<E> = {
ok: false;
error: E;
};
export type Result<T, E = Error> = Success<T> | Failure<E>;
This tiny utility type changes everything. The ok boolean acts as the discriminator. If ok is true, TypeScript knows that value exists. If ok is false, TypeScript knows that error exists.
Applying the Result Pattern in Practice

Let’s rewrite our previous database fetcher using our new Result type. Instead of throwing an exception, we will explicitly return a failure object.
// Define our custom error types
type UserError =
| { type: 'NOT_FOUND'; message: string }
| { type: 'BANNED'; reason: string };
async function getUserProfile(userId: string): Promise<Result<UserProfile, UserError>> {
try {
const user = await db.users.find(userId);
if (!user) {
return {
ok: false,
error: { type: 'NOT_FOUND', message: `User ${userId} not found` }
};
}
if (user.status === 'banned') {
return {
ok: false,
error: { type: 'BANNED', reason: user.banReason }
};
}
return { ok: true, value: user };
} catch (err) {
// We only throw truly unexpected, systemic errors
throw new Error("Database connection failed");
}
}
Now, look at the function signature. It is completely honest. It tells the caller: "I will eventually give you either a UserProfile or a UserError." There are no hidden surprises.
When you consume this function, the developer experience is significantly improved. TypeScript will literally refuse to let you access the user data until you have handled the potential error.
const result = await getUserProfile("123");
// TypeScript Error: Property 'value' does not exist on type 'Result<UserProfile, UserError>'
// console.log(result.value);
if (!result.ok) {
// Thanks to discriminated unions, TypeScript knows 'error' exists here
switch (result.error.type) {
case 'NOT_FOUND':
return showNotFoundPage();
case 'BANNED':
return showBannedOverlay(result.error.reason);
}
}
// After the early return, TypeScript narrows the type!
// It guarantees that 'result' is Success<UserProfile>
renderProfile(result.value);
Notice how flat and readable this code is. We’ve eliminated the nested try/catch scopes. We used an early return to handle the sad path, leaving the main function body dedicated to the happy path. Best of all, we have 100% type safety and auto-completion inside our error handling logic.
Expected Domain Errors vs. Fatal Exceptions
A common question I get when introducing this pattern is: "Should I never use throw again?"
Not at all. The goal isn't to ban exceptions; it's to use them for what they were actually designed for: exceptional, unrecoverable situations.
I like to split errors into two categories:
- Domain Errors (Expected): Things like invalid user input, insufficient funds, duplicated email addresses, or a requested resource not existing. These are part of your normal business logic. The application is designed to recover from them and show a polite message to the user. You should return these using the
Resulttype. - Fatal Errors (Unexpected): Things like the database server crashing, a missing environment variable, out-of-memory issues, or a syntax error in a third-party library. Your application likely cannot recover from these gracefully. You should throw these exceptions and let a top-level error boundary (or middleware) log them to your monitoring system and return a generic 500 error page.
Conclusion
Adopting the Result pattern requires a slight shift in mindset, especially if you have spent years writing standard JavaScript. It requires you to be upfront and explicit about the ways your functions can fail.
However, the payoff is massive. By bringing errors into the type system, you make your codebase self-documenting. You eliminate entire categories of runtime bugs caused by forgotten error handlers. Your colleagues (and future you) will appreciate the predictability. The next time you find yourself about to type throw new Error(...) for a standard business rule, pause and ask yourself: would this be better as a returned value?
written by
Nguyên Tech
Responses
Loading comments…