This is how functional try-catch transforms your JavaScript code

Last updated on October 31, 2023
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 awaiting 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:

  1. The try-catch should be as close to the source of the error as possible, and
  2. 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.

See also