How common is this?
function writeTransactionsToFile(transactions) {
let writeStatus;
try {
fs.writeFileSync('transactions.txt', transactions);
writeStatus = 'success';
} catch (error) {
writeStatus = 'error';
}
// do something with writeStatus...
}
It’s yet another instance where we want a value that depends on whether or not there’s an exception.
Normally, you’d most likely create a mutable variable outside the scope for error-free access within and after the try-catch
.
But it doesn’t always have to be this way. Not with a functional try-catch.
A pure tryCatch()
function avoids mutable variables and encourages maintainability and predictability in our codebase. No external states are modified – tryCatch()
encapsulates the entire error-handling logic and produces a single output.
Our catch
turns into a one-liner with no need for braces.
function writeTransactionsToFile(transactions) {
// 👇 we can use const now
const writeStatus = tryCatch({
tryFn: () => {
fs.writeFileSync('transactions.txt', transactions);
return 'success';
},
catchFn: (error) => 'error';
});
// do something with writeStatus...
}
The tryCatch()
function
So what does this tryCatch()
function look like anyway?
From how we used it above you can already guess the definition:
function tryCatch({ tryFn, catchFn }) {
try {
return tryFn();
} catch (error) {
return catchFn(error);
}
}
To properly tell the story of what the function does, we ensure explicit parameter names using an object argument – even though there are just two properties.
Because programming isn’t just a means to an end — we’re also telling a story of the objects and data in the codebase from start to finish.
TypeScript is great for cases like this, let’s see how a generically typed tryCatch()
could look like:
type TryCatchProps<T> = {
tryFn: () => T;
catchFn: (error: any) => T;
};
function tryCatch<T>({ tryFn, catchFn }: TryCatchProps<T>): T {
try {
return tryFn();
} catch (error) {
return catchFn(error);
}
}
And we can take it for a spin, let’s rewrite the functional writeTransactionsToFile()
in TypeScript:
function writeTransactionsToFile(transactions: string) {
// 👇 returns either 'success' or 'error'
const writeStatus = tryCatch<'success' | 'error'>({
tryFn: () => {
fs.writeFileSync('transaction.txt', transactions);
return 'success';
},
catchFn: (error) => return 'error';
});
// do something with writeStatus...
}
We use the 'success' | 'error'
union type to clamp down on the strings we can return from try
and catch
callbacks.
Asynchronous handling
No, we don’t need to worry about this at all – if tryFn
or catchFn
is async
then writeTransactionToFile()
automatically returns a Promise
.
Here’s another try-catch
situation most of us should be familiar with: making a network request and handling errors. Here we’re setting an external variable (outside the try-catch) based on whether the request succeeded or not – in a React app we could easily set state with it.
Obviously in a real-world app the request will be asynchronous to avoid blocking the UI thread:
async function comment(comment: string) {
type Status = 'error' | 'success';
let commentStatus;
try {
const response = await fetch('https://api.mywebsite.com/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ comment }),
});
if (!response.ok) {
commentStatus = 'error';
} else {
commentStatus = 'success';
}
} catch (error) {
commentStatus = 'error';
}
// do something with commentStatus...
}
Once again we have to create a mutable variable here so it can go into the try-catch
and come out victoriously with no scoping errors.
We refactor like before and this time, we async
the try
and catch
functions thereby await
ing the tryCatch()
:
async function comment(comment: string) {
type Status = 'error' | 'success';
// 👇 await because this returns Promise<Status>
const commentStatus = await tryCatch<Status>({
tryFn: async () => {
const response = await fetch<('https://api.mywebsite.com/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ comment }),
});
// 👇 functional conditional
return response.ok ? 'success' : 'error';
},
catchFn: async (error) => 'error';
});
// do something with commentStatus...
}
Readability, modularity, and single responsibility
Two try-catch
rules of thumb to follow when handling exceptions:
- The
try-catch
should be as close to the source of the error as possible, and - Only use one
try-catch
per function
They will make your code easier to read and maintain in the short- and long-term.
Look at processJSONFile()
here, it respects rule 1. The 1st try-catch is solely responsible for handling file-reading errors and nothing else. No more logic will be added to try
, so catch
will also never change.
And next try-catch
in line is just here to deal with JSON parsing.
function processJSONFile(filePath) {
let contents;
let jsonContents;
// First try-catch block to handle file reading errors
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (error) {
// log errors here
contents = null;
}
// Second try-catch block to handle JSON parsing errors
try {
jsonContents = JSON.parse(contents);
} catch (error) {
// log errors here
jsonContents = null;
}
return jsonContents;
}
But processJsonFile()
completely disregards rule 2, with both try-catch
blocks in the same function.
So let’s fix this by refactoring them to their separate functions:
function processJSONFile(filePath) {
const contents = getFileContents(filePath);
const jsonContents = parseJSON(contents);
return jsonContents;
}
function getFileContents(filePath) {
let contents;
try {
contents = fs.readFileSync(filePath, 'utf8');
} catch (error) {
contents = null;
}
return contents;
}
function parseJSON(content) {
let json;
try {
json = JSON.parse(content);
} catch (error) {
json = null;
}
return json;
}
But we have tryCatch()
now – we can do better:
function processJSONFile(filePath) {
return parseJSON(getFileContents(filePath));
}
const getFileContents = (filePath) =>
tryCatch({
tryFn: () => fs.readFileSync(filePath, 'utf8'),
catchFn: () => null,
});
const parseJSON = (content) =>
tryCatch({
tryFn: () => JSON.parse(content),
catchFn: () => null,
});
We’re doing nothing more than silencing the exceptions – that’s the primary job these new functions have.
If this occurs frequently, why not even create a “silencer” version, returning the try
function’s result on success, or nothing on error?
function tryCatch<T>(fn: () => T) {
try {
return fn();
} catch (error) {
return null;
}
}
Further shortening our code to this:
function processJSONFile(filePath) {
return parseJSON(getFileContents(filePath));
}
const getFileContents = (filePath) =>
tryCatch(() => fs.readFileSync(filePath, 'utf8'));
const parseJSON = (content) => tryCatch(() => JSON.parse(content));
Side note: When naming identifiers, I say we try as much as possible to use nouns for variables, adjectives for functions, and… adverbs for higher-order functions! Like a story, the code will read more naturally and could be better understood.
So instead of tryCatch
, we could use silently
:
const getFileContents = (filePath) =>
silently(() => fs.readFileSync(filePath, 'utf8'));
const parseJSON = (content) => silently(() => JSON.parse(content));
If you’ve used @mui/styles
or recompose
, you’ll see how a ton of their higher-order functions are named with adverbial phrases — withStyles
, withState
, withProps
, etc., and I doubt this was by chance.
Final thoughts
Of course try-catch
works perfectly fine on its own.
We aren’t discarding it, but transforming it into a more maintainable and predictable tool. tryCatch()
is even just one of the many declarative-friendly functions that use imperative constructs like try-catch
under the hood.
If you prefer to stick with direct try-catch
, do remember to use the 2 try-catch
rules of thumb, to polish your code with valuable modularity and readability enhancements.
11 Amazing New JavaScript Features in ES13
This guide will bring you up to speed with all the latest features added in ECMAScript 13. These powerful new features will modernize your JavaScript with shorter and more expressive code.