Replace Nested try…catch with Go‑Style Error Handling in JavaScript

This article explains how async/await simplifies asynchronous JavaScript but still requires try…catch, and shows how to adopt Go‑style error handling with a tiny helper that returns [error, data] to flatten code, reduce boilerplate, and improve readability.

JavaScript
JavaScript
JavaScript
Replace Nested try…catch with Go‑Style Error Handling in JavaScript
async/await

was introduced in ES7 as syntactic sugar that fundamentally changes asynchronous programming in JavaScript, allowing code to be written in a seemingly synchronous style and greatly improving readability and maintainability.

However, the convenience of async/await brings back an "old friend": try...catch.

Root of the problem: try...catch

To catch a rejected await promise, we must wrap the code in a try...catch block. For example, fetching user information from a server:

import { fetchUserById } from './api';
async function displayUser(userId) {
  try {
    const user = await fetchUserById(userId);
    console.log('User info:', user.name);
    // ... more operations based on user
  } catch (error) {
    console.error('Failed to fetch user:', error);
    // ... corresponding error handling, e.g., show a notification
  }
}

When business logic becomes slightly more complex, such as making several sequential requests, the code quickly turns into deeply nested try...catch structures:

async function loadPageData(userId) {
  try {
    const user = await fetchUserById(userId);
    console.log('User info:', user.name);

    try {
      const posts = await fetchPostsByUserId(user.id);
      console.log('User posts:', posts);

      try {
        const comments = await fetchCommentsForPosts(posts[0].id);
        console.log('Post comments:', comments);
      } catch (commentError) {
        console.error('Failed to fetch comments:', commentError);
      }
    } catch (postError) {
      console.error('Failed to fetch posts:', postError);
    }
  } catch (userError) {
    console.error('Failed to fetch user:', userError);
  }
}

Seeing these layers of try...catch, you may feel suffocated. The problems are obvious:

Code redundancy : every async operation repeats the try...catch structure, adding a lot of boilerplate.

Poor readability : the core "happy path" is buried inside nested try blocks, disrupting the natural flow.

Mixed concerns : success logic and failure logic are tightly coupled in the same block, reducing single‑responsibility.

Is there a way out? Absolutely.

Elegant solution: Go‑style error handling

In Go, functions typically return two values: result and error. Callers check whether error is nil to determine success. We can bring this idea into JavaScript async/await by creating a helper function (named to) that accepts a promise and never rejects. Instead, it always resolves to an array [error, data].

If the promise resolves, it returns [null, data].

If the promise rejects, it returns [error, null].

Let's implement the to helper:

async function to(promise) {
  try {
    const data = await promise;
    return [null, data];
  } catch (err) {
    return [err, null];
  }
}

If you are not using TypeScript, the pure JavaScript version looks like the code above.

The to function is tiny but powerful. It encapsulates the try...catch logic internally and exposes a uniform, flat interface.

Practical application: refactor our code

Now let's refactor the previous displayUser function using to:

What changed?

No try...catch any more! The function body becomes flat.

Error‑first handling : we first check and handle errors with guard if statements, returning early.

Highly readable : after error handling, the remaining code is the core success path, clear and unindented.

Now let's tackle the terrifying nested loadPageData:

The difference is striking! The code becomes linear and predictable, with each step's error handling clearly isolated.

Summary of the new pattern's advantages

Flatter, clearer code : eliminates nested try...catch, keeping core logic at the top level.

Reduced boilerplate : error handling is encapsulated in the reusable to function.

Forced error handling : destructuring const [error, data] forces developers to acknowledge error and not overlook it.

Separation of concerns : guard clauses separate error handling from success logic, making maintenance easier.

Combine with Promise.all

This pattern also shines when handling multiple concurrent requests.

async function loadDashboard(userId) {
  const [
    [userError, userData],
    [settingsError, settingsData]
  ] = await Promise.all([
    to(fetchUser(userId)),
    to(fetchUserSettings(userId))
  ]);

  if (userError) {
    console.error('Failed to load user data');
    // handle user error
  }

  if (settingsError) {
    console.error('Failed to load user settings');
    // handle settings error
  }

  // Even if one fails, the other successful data remains usable
  if (userData) {
    // ...
  }
  if (settingsData) {
    // ...
  }
}

Using Promise.all together with to, you can gracefully handle scenarios where some promises succeed and others fail, whereas a traditional try...catch would abort on the first rejection and discard all results. try...catch remains the cornerstone of JavaScript error handling; we are not removing it entirely. Instead, we abstract and encapsulate it inside the to helper, avoiding repetitive manual try…catch blocks in business code.

JavaScriptError Handlingasync/awaitPromisetry-catchGo style
JavaScript
Written by

JavaScript

Provides JavaScript enthusiasts with tutorials and experience sharing on web front‑end technologies, including JavaScript, Node.js, Deno, Vue.js, React, Angular, HTML5, CSS3, and more.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.