Logo
A Cleaner Way to Handle Async Errors in TypeScript
TypeScriptJan 20262 min readQuick Read

A Cleaner Way to Handle Async Errors in TypeScript

Tired of nested try-catch blocks everywhere? This simple TypeScript pattern wraps any async operation and returns [data, error] — keeping your code flat, consistent, and easy to follow.

If you've worked with multiple async operations in a row, you've probably ended up with something that looks like a pyramid — try-catch inside try-catch inside try-catch. It works, but it's hard to read, hard to maintain, and it scatters your error handling logic all over the place. There's a better way.

The Pattern

The idea is to write a single generic helper function called tryCatch. It takes a promise, and instead of letting it throw, it always returns a tuple: [data, null] on success, and [null, error] on failure. That's it. One function, two possible shapes, and your async code never throws again.

Here's the full implementation in TypeScript:

ts
type SuccessResult<T> = readonly [T, null];
type ErrorResult<E = Error> = readonly [null, E];
type Result<T, E = Error> = SuccessResult<T> | ErrorResult<E>;

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

The generics keep everything fully typed. TypeScript knows exactly what shape the returned tuple will be in both the success and failure cases.

The Problem It Solves

Imagine you need to fetch a user, then fetch their posts, then fetch the comments on the first post — three sequential async operations, each of which can fail independently. Without this pattern, handling each failure properly leads to this:

ts
// ❌ Before — Nested Try-Catch
async function fetchUserData(id: string) {
  try {
    const user = await getUser(id);
    try {
      const posts = await getUserPosts(user.id);
      try {
        const comments = await getComments(posts[0].id);
        return { user, posts, comments };
      } catch (error) {
        return handleError(error);
      }
    } catch (error) {
      return handleError(error);
    }
  } catch (error) {
    return handleError(error);
  }
}

Every level of nesting is another async operation. The logic is buried, the indentation keeps growing, and every catch block is doing the same thing. Now here's the exact same function using the tryCatch pattern:

ts
// ✅ After — Clean & Flat
async function fetchUserData(id: string) {
  const [user, userErr] = await tryCatch(getUser(id));
  if (userErr) return handleError(userErr);

  const [posts, postsErr] = await tryCatch(getUserPosts(user.id));
  if (postsErr) return handleError(postsErr);

  const [comments, commentsErr] = await tryCatch(getComments(posts[0].id));
  if (commentsErr) return handleError(commentsErr);

  return { user, posts, comments };
}

Completely flat. Each operation is one line, each error check is one line, and you can read the happy path straight from top to bottom without jumping around nested blocks.

Real-World Usage: Next.js Server Components

This pattern is especially valuable in Next.js Server Components, where you're often fetching data directly inside the component. The standard approach wraps everything in a single try-catch, which means any error from any fetch call lands in the same catch block — you lose granularity over what actually failed:

tsx
// ❌ Before — Server Component without the pattern
export default async function UserPage({ params }: Props) {
  try {
    const user = await fetchUser(params.id);
    const posts = await fetchUserPosts(user.id);

    return <UserProfile user={user} posts={posts} />;
  } catch (error) {
    return <ErrorState error={error} />;
  }
}

With the pattern, each fetch operation has its own error check, and you can respond to each failure differently if needed:

tsx
// ✅ After — Server Component with the pattern
export default async function UserPage({ params }: Props) {
  const [user, userError] = await tryCatch(fetchUser(params.id));
  if (userError) return <ErrorState error={userError} />;

  const [posts, postsError] = await tryCatch(fetchUserPosts(user.id));
  if (postsError) return <ErrorState error={postsError} />;

  return <UserProfile user={user} posts={posts} />;
}
💡

Notice that after each `tryCatch` call, TypeScript knows that if the error is null, the data is guaranteed to be defined. You get proper type narrowing without any extra gymnastics.

YS

Yousef Saeed

Full-Stack Developer · Cairo, Egypt