I Stopped Using Try-Catch in TypeScript and You Should Too

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('user@example.com', '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.
You might also like
How Do You Know If You Have Product-Market Fit?
The brutal truth about product-market fit: if you're asking whether you have it, you don't. Learn the real signs of PMF, why most startups get it wrong, and the two paths to finding it.
I Replaced MongoDB with a Single Postgres Table
Discover how Postgres and its JSONB data type can replace MongoDB for most NoSQL use cases. Get schema flexibility, ACID compliance, and fast queries from a single table.
Customer Retention Metrics for SaaS: The Complete Guide
Master SaaS retention metrics: Calculate churn rate, NRR, LTV, and 15+ KPIs. Get proven strategies, real benchmarks, and actionable frameworks to reduce churn by 50%.
Why Most Startup Founders Fail
Why founders fail: building without validation, poor distribution, and giving up too early. Plus a simple framework to avoid these mistakes.