DEV Community

Cover image for I Stopped Using Try-Catch in TypeScript and You Should Too
Shayan
Shayan

Posted on • Originally published at userjot.com

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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'>;
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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'
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

"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);
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
lemii_ profile image
Lemmi

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.

Collapse
 
dariomannu profile image
Dario Mannu

they may need lots of byte-sized examples they can copy, absorb and replicate plus a bit of time...

Collapse
 
vince_coppola_cc0f46e2439 profile image
Vince Coppola

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's Result 💯

Collapse
 
dinakajoy profile image
Odinaka Joy

Even OCaml which made me love statically-typed languages more. Great post

Collapse
 
shayy profile image
Shayan

Yeah i wish it was the default in JS xD

Collapse
 
code42cate profile image
Jonas Scholz

🍿

Collapse
 
john_p_wilson profile image
John Wilson

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.

Collapse
 
kurealnum profile image
Oscar

This makes me very grateful for how Rust handles errors. Most of what you just described comes out of the box in Rust.

Collapse
 
kristofer_meetvista profile image
Kristofer

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?

Collapse
 
xwero profile image
david duymelinck

Lets take in account the "Problem with throwing" section, and rewrite the code.

class UserNotFound extends Error {
  constructor(message: string = 'User not found') {
    super(message);
    this.name = 'UserNotFound';
  }
}

class UserNotActive extends Error {
  constructor(message: string = 'User is not active') {
    super(message);
    this.name = 'UserNotActive';
  }
}

async function getUser(id: string): Promise<User | UserNotFound | UserNotActive> {
  const user = await db.query(`SELECT * FROM users WHERE id = ?`, [id]);
  if (!user) {
    return new UserNotFound();
  }
  if (!user.isActive) {
    return new UserNotActive();
  }
  return user;
}
Enter fullscreen mode Exit fullscreen mode
  1. The function signature is not lying to you anymore
  2. You know what output types to expect
  3. You are forced to check the output type
  4. The errors have context

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.