The Death of Try/Catch: A Better Way to Handle Errors in TypeScript

Modern Error Handling Patterns for Safer, Cleaner, and More Predictable TypeScript Applications

ยท
3 min read
The Death of Try/Catch: A Better Way to Handle Errors in TypeScript

If you write JavaScript or TypeScript, your asynchronous code probably looks like a massive nesting doll of try/catch blocks.

async function fetchUserData(userId: string) {
  try {
    const user = await db.users.find(userId);
    try {
      const posts = await api.fetchPosts(user.id);
      return { user, posts };
    } catch (apiError) {
      console.error("Failed to fetch posts");
      throw new Error("Post fetching failed");
    }
  } catch (dbError) {
    console.error("Failed to fetch user");
    throw new Error("Database failed");
  }
}

This pattern is deeply flawed for three reasons:

  1. Scope leakage: Variables defined inside try cannot be accessed outside of it without declaring them as let beforehand.

  2. Loss of Type Safety: In TypeScript, errors caught in a catch(error) block are always typed as unknown or any. You have no idea what actually failed.

  3. The "Throw everything" antipattern: We use errors for normal control flow, making it impossible to know if a function will actually return a value or blow up the call stack.

Let's look at a much better, highly practical way to handle errors in TypeScript.


The Golang Approach: Errors as Values

In languages like Go and Rust, errors are not thrown into the void; they are returned as standard values. We can easily adopt this pattern in TypeScript.

Instead of returning Data and throwing an Error, we return a tuple: [Error | null, Data | null].

Creating the Wrapper

First, let's create a tiny utility function that wraps any Promise:

// utils/catchAsync.ts

type SafeReturn<T, E = Error> = 
  | [E, null]
  | [null, T];

export async function catchAsync<T, E = Error>(
  promise: Promise<T>
): Promise<SafeReturn<T, E>> {
  try {
    const data = await promise;
    return [null, data];
  } catch (error) {
    return [error as E, null];
  }
}

Refactoring our Code

Now, let's rewrite our nested try/catch disaster using our new catchAsync utility.

import { catchAsync } from './utils/catchAsync';

async function fetchUserData(userId: string) {
  // 1. Fetch User
  const [userError, user] = await catchAsync(db.users.find(userId));
  
  if (userError) {
    console.error("Database failed", userError);
    return null; // Handle it gracefully, right here.
  }

  // 2. Fetch Posts (we know 'user' is safely defined here)
  const [postError, posts] = await catchAsync(api.fetchPosts(user.id));
  
  if (postError) {
    console.error("API failed", postError);
    // Maybe we just return the user with empty posts if the API is down
    return { user, posts: [] }; 
  }

  // 3. Success
  return { user, posts };
}

Why is this vastly superior?

  1. Straight-line Code: We've completely eliminated nesting. The code reads cleanly from top-to-bottom.

  2. Const correctness: We can use const for user and posts because they are instantiated in the main scope, not trapped inside a try block.

  3. Forced Error Handling: Because you must destructure the error [error, data], TypeScript practically forces you to acknowledge that an error might happen before using the data.

  4. Type Narrowing: When you check if (error), TypeScript automatically narrows the type of data to exactly T in the rest of the function block. No more unknown.

Advanced: The Result Pattern (NeverThrow)

If you want to take this to the absolute limit of functional type safety, you can use the Result pattern (heavily inspired by Rust), often implemented via libraries like neverthrow.

npm install neverthrow

Using neverthrow, functions explicitly return a Result object that is either ok or err.

import { Result, ok, err } from 'neverthrow';

// Function explicitly declares it returns a User OR a DatabaseError
function getUser(id: string): Result<User, DatabaseError> {
  const user = db.find(id);
  
  if (!user) {
    return err(new DatabaseError("User not found"));
  }
  
  return ok(user);
}

const result = getUser("123");

if (result.isErr()) {
  // TypeScript knows result.error is exactly 'DatabaseError'
  console.log(result.error.message);
} else {
  // TypeScript knows result.value is exactly 'User'
  console.log(result.value.name);
}

Conclusion

Stop using try/catch for expected application logic (like an API returning a 404, or a database query finding no records). Reserve throw for truly exceptional, catastrophic failures (like running out of memory or infinite loops).

By treating errors as standard return values, your code becomes highly predictable, far easier to test, and perfectly typed.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.