"Do I wrap my very specific code with a try-catch and move the variable creation outside the scope, or do I just keep expanding the catch scope as I go?"
We’ve all been there—balancing clean, readable code with the messy reality of error handling. On one hand, we want to avoid cluttering our logic with boilerplate. On the other, we don't want to deal with awkward scoping issues or argue with the linter about whether a variable exists. So, what’s the “right” answer? Well... there sort of is one. But it’s not found in JavaScript. Or C#.
It’s found in Go.
But how does a language remove the need for something as deeply embedded in frontend and backend development as try-catch?
If you ask me, try-catch is like the plague. It’s essentially a glorified GOTO statement,[1] just dressed up in structured programming syntax. When an error is thrown, execution jumps—often unpredictably—to a separate block of code, potentially far removed from the scope in which the error occurred. This makes following the logical flow of a program harder, and managing side effects even trickier.
When I first encountered try-catch, coming from a background in batch scripting—where GOTO was king—it seemed like a programming savior. It caught errors! It let me ignore them! It gave me the illusion of control. But eventually, I realized that it was adding cognitive load and making my code harder to follow. Sure, it may avoid full-page crashes—but at a cost.
Here are two examples of the different approaches I mentioned. Either you have an ever-growing scope:
try { const userData = await api.getUserData(); const organizationData = await api.getOrganizationData(userData.organizationId); return { userData, organizationData }; } catch (e) { // Handle multiple errors }
Or where variables "escape" the scope of the try-catch:
let userData: UserData | null = null; let organizationData: OrganizationData | null = null; try { userData = await api.getUserData(); } catch (e) { // Handle/rethrow/rewrap error } if (!userData) { // Potential further error-throwing due to missing data } try { organizationData = await api.getOrganizationData(userData.organizationId); } catch (e) { // Handle/rethrow/rewrap error } if (!organizationData) { // Potential further error-throwing due to missing data } return { userData, organizationData };
Languages like C# have tried to improve things with features like type-conditional catches, but ultimately, the same issues persist: the more logic you put in a try
block, the more your catch
turns into a cluttered mess of conditionals. And if you keep try scopes tight, you often find yourself battling linters or ending up with excessive optional chaining. At Pistachio, we're transitioning to an approach inspired by Go—and it’s been a game changer.
Go’s Simple, Elegant Approach
For readers who haven’t had the pleasure of writing Go yet—strap in. Go makes you think about errors in every function. Always. In theory, a Go program shouldn’t have runtime exceptions in the traditional sense. The pattern is simple: a function returns either a result and nil
for the error, or nil
for the result and a real error.
Here’s a basic, simplified example:
u, err := getUserData(userID) if err != nil { return fmt.Errorf("error getting user in get user handler: %w", err) } err = sendUserNextSimulation(u) if err != nil { return fmt.Errorf("error sending simulation to user in handler: %w", err) } return nil
This mutual exclusivity keeps things clean. If you receive an error, there is no result—and vice versa. You handle it immediately, right there, while the context is still fresh in your head. No jumping. No surprises. No GOTO.
To adopt this in JavaScript, we’ve made a few tweaks. Our pattern flips the position of the values to emphasize handling the error first. And in our refactored code, we don’t throw—ever.
So, let's refactor the two examples from earlier, both using try-catch in different ways:
const [errUser, userData] = await httpClient.get('/users/{userId}', { userId }); if (errUser) { return [new Error('error getting user-data in loader', { cause: errUser }), null]; } const [errOrganization, organizationData] = await httpClient.get('organizations/{organizationId}', { organizationId: userData.organizationId, }); if (errOrganization) { return [new Error('error getting organization-data in loader', { cause: errOrganization }), null]; } return [null, { userData, organizationData }];
The resulting code might have a higher line count, but ultimately we end up with code that’s clearer, more concise, and easier to debug. You get a direct 1:1 relationship between error and handling. You don’t need to worry about try-catch scopes or jump between lines to understand the full picture.
Wrapping and Extending
For third-party or legacy code that still throws, we use a wrapper that converts thrown exceptions into our preferred return-style format. That allows us to keep a consistent error-handling model across the board—without rewriting every package or SDK we interact with.
Here’s our simple wrapper that turns try-catchable promises into [error, result]
tuples:
export type SafeWrap<ErrorType = Error, DataType = unknown> = | [error: ErrorType, data: null] | [error: null, data: DataType]; export async function safeWrap<ErrorType = Error, DataType = unknown>( promise: () => Promise<DataType>, ): SafeWrap<ErrorType, DataType> { try { const data = await promise(); return [null, data]; } catch (error) { return [error as ErrorType, null]; } }
As demonstrated, try-catch is still a "necessary evil"—but we can abstract it away, so that any consumer (internal or external) stays clear and readable:
const [err, file] = await safeWrap(() => storageClient.get('/file-reference')); if (err) { return [new Error('error getting file-reference from storageClient', { cause: err }), null]; } return [null, file];
The result? Safer, clearer, and more debuggable code. We’re not skipping error handling anymore—we’re required to confront it as it arrives. Of course, try-catch still exists in our codebase. There’s no way to fully eliminate it, especially when integrating with code we don’t control. But we’ve been able to abstract it away, reduce its surface area, and let developers write cleaner, more focused logic.
Objectively? This is just a better way to handle errors. Cleaner, more readable, and without the ancient baggage of jumping from one part of your logic to another. It’s time for try-catch—the modern GOTO—to finally GOAWAY.
Even if this is a somewhat optimistic interpretation of Dijkstra’s[2] famous words, I like to think he'd agree:
“The quality of programmers is determined by the quality of their control structures.”
You made it this far, so maybe we’ve won one more developer over to a better way of handling errors in JavaScript—and maybe even in programming as a whole.