Try/catch blocks are a common method of error-checking, but they also add a fair bit of verbosity to the code. In this post, I'll describe one method I've used to cut down on their clutteriness.
The line of code below intends to convert a JSON string into a JavaScript object. However, because the string is missing quotes around property names, the call to JSON.parse() generates a syntax error.
const object = JSON.parse('{a: 1, b: 2}')
We can guard against those kinds of errors by wrapping the code in a try/catch block:
try { const object = JSON.parse('{a: 1, b: 2}'); // Do something with the object, as it was successfully parsed. } catch (error) { // We can handle the error here. console.log("Failed to convert"); } finally { // Some actions that we may want to perform regardless of errors. console.log("Done"); }
Although the code is now fortified against some of the errors that might occur from JSON.parse(), it's also much more verbose, and will keep becoming more so as we add more try/catch blocks.
For one of my projects (Lintulista), I wanted to create a less cluttery method of try/catch error-checking – one that would (1) minimize repetitive in-place error-checking code, especially for async network operations; and (2) automatically notify the app's UI about successes and failures in a way that's relevant to the end-user.
For those purposes, I created what I called the action system. The system gets its name from the fact that it bundles a set of related commands into an action, each action being wrapped in a try/catch(/finally) block and associated with a pre-defined, non-technical, user-facing error and/or success message. Essentially, the user-facing messages define the thematic context of the action, and the action's commands are selected so that e.g. if any single one of them fails, the action's error message is relevant to be shown.
An example of an action would be logging in to your user account: one command queries your credentials, and another attempts to log you in with those credentials. If either command fails, the action of logging in can be considered to have failed; the action system then consumes the failure and dispatches the action's pre-defined user-facing error message. The code calling the action receives a return value indicating whether the action succeeded or failed, along with any data that the action itself returned.
The following sample code creates an action – parse_json() – for parsing a JSON string:
import {AsyncAction} from './widgets/action-sample/action.js'; const parse_json = AsyncAction({ success: "Parsed the JSON string", failure: "Failed to parse the JSON string", act({string}) { return JSON.parse(string); }, announcer: ({message})=>{ document.getElementById("message").textContent = message; }, error: ({error})=>{ document.getElementById("error").textContent = error; }, });
When parse_json() is called, the action system invokes the act() function that we passed to the AsyncAction factory. If that function throws, error() will be notified. Dependent on whether act() returns or throws, the success or failure string will be passed to accouncer().
const object = await parse_json({string: '{a: 1, b: 2}'});
Because the input JSON string is invalid, the call to parse_json() generates an error. However, since the error-handling routines are already baked into parse_json(), the caller doesn't need to define them explicitly. The action system also took care of letting the UI know about the error – both with a technical error message and the action's pre-defined user-facing error string.
(The parsing of a JSON string is generally not something you'd think of as an async action, but due to the implementation of the action system here, the call to parse_json() returns a Promise and so needs to be handled with e.g. await. In the original implementation for Lintulista, the call would've been parse_json.async() to make that fact clear, but here I've cut some corners for the purposes of demonstration.)
The following is a slightly more involved example of the action system in use, with more in-code explanations too. It makes an async network request for a random number, then displays the response in the UI.
We'll start by defining a set of UI elements to receive our data.
Next, we'll create an action for async-fetching the random number via a (dummy) network. The action will update the UI depending on the network's response.
const fetch_random_number = AsyncAction({ success: "The action succeeded", failure: "The action failed", async act() { const valueInRange0to2 = await send_network_query(); // On 0, we cancel the action (by returning undefined). // On 1, we successfully complete the action (by returning a value). // On 2, we terminate the action with an error (by throwing). switch (valueInRange0to2) { case 0: return; case 1: return Math.random(); default: throw new Error("Simulated error"); } }, // Gets called after the action has been attempted; regardless of // whether the action succeeded, failed, or was canceled. Receives // the arguments with which the action was invoked. finally() { document.getElementById("spinner").style.display = "none"; }, // Gets called if the action is canceled. Receives the arguments with // which the action was invoked. canceled() { document.getElementById("canceled").style.display = "flex"; }, // If the action fails, receives the error that was thrown and the // arguments with which the action was invoked. This won't be triggered // for canceled actions. error({error}) { console.warn(error); }, // Receives the 'success' or 'failure' message, depending on whether // the action succeeded or failed, and the arguments with which the // action was invoked. This won't be triggered for canceled actions. announcer({message}) { const labelEl = document.getElementById("label"); labelEl.classList.add((message === this.success)? "success" : "failure"); labelEl.textContent = message; } }); // Simulates an async network request that returns a pseudo-random // integer in the range [0,2]. function send_network_query(latencyMs = 500) { const randomNumber = Math.round(Math.random() * 2); return new Promise(resolve=>{ setTimeout(()=>resolve(randomNumber), latencyMs); }); }
We can now invoke the action by calling fetch_random_number():
const result = await fetch_random_number(); document.getElementById("result").textContent = `${result}`; // If the action was canceled. if (result === undefined) { } // If the action failed. if (result === null) { } // If the action succeeded (note the non-strict equality). if (result != null) { }
Before fetch_random_number() returns, the announcer() function will take care of updating the user-facing status message, and the finally() function will get rid of the async waiting spinner in the UI. The code calling the action doesn't need to care about any of those things, and can limit itself to testing the return value and branching accordingly.
You can give the code a spin below. It chooses at random whether the network responds with a random number (success), an error (failure), or a cancelation of the action. In case of an error, a message will also be printed into the browser's developer console.
The source code for the AsyncAction implementation as used in this post is available on GitHub.
The source code for the original action system as used in Lintulista is likewise available on GitHub.
It's not to say that the action system described here is without issues, but I think it's a reasonable try/catch wrapper overall. Exceptions thrown in deep async code might not get picked up correctly, though, and the stack traces may not always be the most useful (it's been a while since I worked on Lintulista, so the details escape me, but that's what I seem to remember).
If you want to share ideas for improvement, feel free to