Clean Code: Handling Errors Gracefully

Things go wrong. It's a universal truth in software development. Network requests fail, files are not found, and users enter invalid data. Clean error handling isn't about preventing errors, but about managing them in a way that keeps your code readable and your application predictable. One of the core ideas is to use exceptions for exceptional situations, rather than returning error codes.

Let's look at an example.

Exceptions Over Error Codes

Returning error codes or null from functions can lead to a lot of conditional checks, cluttering up your code and making it hard to follow the "happy path".

// Bad: Returning a string to indicate an error
function parseJson(jsonString: string): object | string {
  try {
    return JSON.parse(jsonString);
  } catch {
    return 'Error: Invalid JSON';
  }
}

const result = parseJson('{"name": "Alice"');
if (typeof result === 'string') {
    console.error(result); // We have to check the type to know if it's an error
} else {
    // use result
}


// Good: Throwing an exception for an exceptional case
function parseJsonSafe(jsonString: string): object {
  try {
    return JSON.parse(jsonString);
  } catch (error) {
    throw new Error('Invalid JSON format');
  }
}

try {
  const data = parseJsonSafe('{"name": "Alice"');
  // Use data
} catch (error) {
  // The catch block cleanly separates the error handling logic
  console.error('Failed to parse JSON:', error.message);
}

By throwing an exception, we let the calling code decide how to handle the error, and the main flow of our code remains clean.

Don't Use Exceptions for Flow Control

Exceptions are for, well, exceptional situations. They shouldn't be used for normal application flow.

// Bad: Using try/catch for normal logic
try {
    // Trying to find a user
    const user = findUser(id);
    // do something with user
} catch (error) {
    // If not found, create a new one
    const newUser = createUser(id);
}

// Good: Using conditional logic for expected paths
const user = findUser(id);
if (user) {
    // do something with user
} else {
    // If not found, create a new one
    const newUser = createUser(id);
}

Tips for Clean Error Handling

  • Use exceptions for errors: This helps separate the error-handling logic from the main program flow.
  • Handle errors at the right level: Don't just swallow errors. Let them propagate up to a level that has enough context to handle them properly.
  • Provide context with your errors: When you throw an error, include a descriptive message. Error: 'Failure' isn't nearly as helpful as Error: 'Could not connect to database at...'.

Clean error handling makes your application more robust and easier to debug. When an error occurs, you'll know exactly where and why, which is half the battle won.

// Bad
function divide(a: number, b: number): number | string {
  if (b === 0) {
    return 'Error: Division by zero';
  }
  return a / b;
}

// Good
function divide(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}