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.
async/awaitwas 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.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
