I'm about to share something that might get me canceled by the JavaScript community: I've stopped throwing errors in TypeScript, and I'm never going back.
Yes, you read that right. No more try-catch
blocks. No more wondering which function might explode. No more "but what if it throws?" anxiety.
Instead, I return my errors. And it's glorious.
The Problem with Throwing
Here's typical TypeScript code:
async function getUser(id: string): Promise<User> {
const user = await db.query(`SELECT * FROM users WHERE id = ?`, [id]);
if (!user) {
throw new Error('User not found');
}
if (!user.isActive) {
throw new Error('User is not active');
}
return user;
}
// Somewhere else in your code...
try {
const user = await getUser('123');
console.log(user.name);
} catch (error) {
// What kind of error? Who knows! 🤷
console.error(error);
}
What's wrong with this picture?
-
The function signature lies - It says it returns
Promise<User>
, but it might throw instead - You can't see what errors are possible - TypeScript won't tell you
-
Error handling is an afterthought - Easy to forget that
try-catch
- Errors lose context - Good luck figuring out if it was a network error, validation error, or something else
Enter the Result Type
Here's how I write the same code now:
// First, let's define our Result type
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
// Helper functions to create Results
const Ok = <T, E>(value: T): Result<T, E> => ({ ok: true, value });
const Err = <T, E>(error: E): Result<T, E> => ({ ok: false, error });
// For async functions
type AsyncResult<T, E> = Promise<Result<T, E>>;
// Now let's define our app-specific errors
type AppError =
| { type: 'USER_NOT_FOUND' }
| { type: 'UNAUTHORIZED' }
| { type: 'VALIDATION_ERROR'; field: string }
| { type: 'DATABASE_ERROR'; message: string };
// Here's our new getUser function
async function getUser(id: string): AsyncResult<User, AppError> {
const user = await db.query(`SELECT * FROM users WHERE id = ?`, [id]);
if (!user) {
return Err({ type: 'USER_NOT_FOUND' });
}
if (!user.isActive) {
return Err({ type: 'UNAUTHORIZED' });
}
return Ok(user);
}
// Using it is explicit and safe
const result = await getUser('123');
if (!result.ok) {
// TypeScript knows result.error is AppError
switch (result.error.type) {
case 'USER_NOT_FOUND':
console.log('User does not exist');
break;
case 'UNAUTHORIZED':
console.log('User is not active');
break;
// TypeScript ensures we handle all cases!
}
return;
}
// TypeScript knows result.value is User here
console.log(result.value.name);
Why This Changes Everything
1. Errors Become Part of Your API
Your function signatures now tell the complete truth:
// Before: Lies! Could throw anything
function divide(a: number, b: number): number;
// After: Honest about what could go wrong
function divide(a: number, b: number): Result<number, 'DIVISION_BY_ZERO'>;
2. TypeScript Forces You to Handle Errors
You literally cannot access the value without checking if the operation succeeded:
const result = await fetchUserProfile(userId);
// This won't compile - TypeScript knows result might be an error
console.log(result.value.name); // ❌ Property 'value' does not exist
// You MUST check first
if (result.ok) {
console.log(result.value.name); // ✅ Now it works!
}
3. Granular Error Handling
Different errors can be handled differently, and TypeScript ensures you don't miss any:
type PaymentError =
| { type: 'INSUFFICIENT_FUNDS'; required: number; available: number }
| { type: 'CARD_DECLINED'; reason: string }
| { type: 'NETWORK_ERROR' }
| { type: 'INVALID_AMOUNT' };
async function processPayment(
amount: number
): AsyncResult<PaymentReceipt, PaymentError> {
// Implementation...
}
const result = await processPayment(100);
if (!result.ok) {
switch (result.error.type) {
case 'INSUFFICIENT_FUNDS':
console.log(
`Need $${result.error.required}, but only have $${result.error.available}`
);
break;
case 'CARD_DECLINED':
console.log(`Card declined: ${result.error.reason}`);
break;
case 'NETWORK_ERROR':
console.log('Please check your connection');
break;
case 'INVALID_AMOUNT':
console.log('Invalid payment amount');
break;
}
}
4. Composability
Chaining operations becomes explicit and type-safe:
async function createOrder(
userId: string,
items: Item[]
): AsyncResult<Order, AppError> {
// First, get the user
const userResult = await getUser(userId);
if (!userResult.ok) {
return userResult; // Propagate the error
}
// Then validate the items
const validationResult = validateItems(items);
if (!validationResult.ok) {
return validationResult;
}
// Finally create the order
const order = await db.createOrder(userResult.value, validationResult.value);
return Ok(order);
}
But What About...?
"This is more verbose!"
Yes, it is. But that verbosity is honesty. You're explicitly handling errors instead of pretending they don't exist. Your future self (and your teammates) will thank you when debugging at 3 AM.
"What about unexpected errors?"
You can still use try-catch for truly unexpected errors (like out-of-memory). But for your business logic errors? Return them.
async function safeWrapper<T, E>(
fn: () => AsyncResult<T, E>
): AsyncResult<T, E | { type: 'UNEXPECTED_ERROR'; message: string }> {
try {
return await fn();
} catch (error) {
return Err({
type: 'UNEXPECTED_ERROR',
message: error instanceof Error ? error.message : 'Unknown error'
});
}
}
"This isn't idiomatic JavaScript!"
You're right. It's not. But TypeScript isn't JavaScript - it's a language designed to add type safety. Why not use it to make our error handling safer too?
Real-World Example
Here's a complete example showing how this pattern shines in practice:
type AuthError =
| { type: 'INVALID_CREDENTIALS' }
| { type: 'ACCOUNT_LOCKED'; until: Date }
| { type: 'EMAIL_NOT_VERIFIED' };
type TokenError = { type: 'TOKEN_EXPIRED' } | { type: 'TOKEN_INVALID' };
async function login(
email: string,
password: string
): AsyncResult<{ user: User; token: string }, AuthError | TokenError> {
// Validate credentials
const credResult = await validateCredentials(email, password);
if (!credResult.ok) {
return credResult;
}
// Check if account is locked
if (
credResult.value.lockedUntil &&
credResult.value.lockedUntil > new Date()
) {
return Err({ type: 'ACCOUNT_LOCKED', until: credResult.value.lockedUntil });
}
// Check email verification
if (!credResult.value.emailVerified) {
return Err({ type: 'EMAIL_NOT_VERIFIED' });
}
// Generate token
const tokenResult = await generateToken(credResult.value);
if (!tokenResult.ok) {
return tokenResult;
}
return Ok({
user: credResult.value,
token: tokenResult.value
});
}
// Usage is crystal clear
const loginResult = await login('[email protected]', 'password123');
if (!loginResult.ok) {
switch (loginResult.error.type) {
case 'INVALID_CREDENTIALS':
showError('Invalid email or password');
break;
case 'ACCOUNT_LOCKED':
showError(
`Account locked until ${loginResult.error.until.toLocaleString()}`
);
break;
case 'EMAIL_NOT_VERIFIED':
showError('Please verify your email first');
break;
case 'TOKEN_EXPIRED':
case 'TOKEN_INVALID':
showError('Authentication failed, please try again');
break;
}
return;
}
// Success path - TypeScript knows we have user and token
localStorage.setItem('token', loginResult.value.token);
redirectToDashboard(loginResult.value.user);
This Pattern in Production
I've been dogfooding this pattern while building UserJot, my SaaS for collecting user feedback, managing roadmaps, and publishing beautiful changelogs. If you're building a product and want to stay close to your users (which you absolutely should!), you might want to give it a try.
But back to error handling, implementing this pattern throughout UserJot has been absolutely amazing. Every API endpoint, every database query, every third-party integration, they all return Results.
Here's a real example from UserJot's codebase:
type FeedbackError =
| { type: 'BOARD_NOT_FOUND' }
| { type: 'RATE_LIMITED'; retryAfter: number }
| { type: 'INVALID_CONTENT'; reason: string }
| { type: 'GUEST_POSTING_DISABLED' };
async function submitFeedback(
boardId: string,
content: string,
userId?: string
): AsyncResult<Feedback, FeedbackError> {
// Every operation returns a Result - no surprises!
}
The result? Zero unexpected errors in production. When something goes wrong, we know exactly what it was and can show users helpful messages instead of generic "Something went wrong" errors.
The Bottom Line
Returning errors instead of throwing them makes your code:
- More honest - Function signatures tell the whole story
- More safe - TypeScript ensures you handle errors
- More maintainable - Errors are documented in the type system
- More debuggable - You can see exactly what went wrong and where
Yes, it's different. Yes, it's more verbose. But it's also more correct.
Try it on your next project. Start with one module. Return your errors. See how it feels to have TypeScript actually help you handle errors instead of letting them blow up in production.
Your users (and your on-call rotation) will thank you.
P.S. Speaking of keeping users happy, if you're building a product and need a clean way to collect feedback, manage your roadmap, and keep users updated with a beautiful changelog, check out UserJot. It's built with all the error handling patterns described above, so you know it won't let you down!
Top comments (10)
Honestly the result & option patterns are the best patterns I’ve seen in so long. They just make more sense but so many people will be stuck in their ways.
they may need lots of byte-sized examples they can copy, absorb and replicate plus a bit of time...
Love it. This is actually a popular/modern practice and has first-class support in several other languages, e.g. C++'s
std::expected
and Rust'sResult
💯Even OCaml which made me love statically-typed languages more. Great post
Yeah i wish it was the default in JS xD
🍿
Yeah, after spending some time with Go/Rust, I've started to use the same pattern in TypeScript. It is generally much more verbose and stable, and for common error types I create a dedicated error handlers, which are type-safe too. Overall, this is really nice solution.
This makes me very grateful for how Rust handles errors. Most of what you just described comes out of the box in Rust.
What if we implement custom errors and throw them, extend basic error? Our custom error can implement the error code and we can still use throw / catch?
Lets take in account the "Problem with throwing" section, and rewrite the code.
The extra benefit is that the errors are checked on class name instead of on a string. This reduces errors by typos.
The main driver in the
Result
solution is that instead of throwing the errors they are returned. So you don't need the type to make the solution happen.Some comments may only be visible to logged-in visitors. Sign in to view all comments.