This is how functional try-catch transforms your JavaScript code

Last updated on June 08, 2024
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.

Coding Beauty Assistant logo

Try Coding Beauty AI Assistant for VS Code

Meet the new intelligent assistant: tailored to optimize your work efficiency with lightning-fast code completions, intuitive AI chat + web search, reliable human expert help, and more.

See also