This is how functional try-catch transforms your JavaScript code
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.