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

Published on
Written by Shayan Taslim
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?

  1. The function signature lies - It says it returns Promise<User>, but it might throw instead
  2. You can’t see what errors are possible - TypeScript won’t tell you
  3. Error handling is an afterthought - Easy to forget that try-catch
  4. 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.