Week 6 - Advanced topics

Synchronous File IO

File processing

Error handling

Array methods

Closures

Style - How to write meaningful comments

Practice

Assignment

Core program

Why Error Handling Matters

Programs fail. Files don't exist, data is malformed, operations fail. Without error handling, your program crashes with cryptic messages. With it, you can catch problems and respond gracefully.

When working with files, many things can go wrong:

Let's see what happens without error handling:

import fs from 'node:fs';

// This crashes if the file doesn't exist
const data = fs.readFileSync('missing.txt', 'utf8');
console.log(data);
console.log('Program continues...'); // This never runs!

Error output:

Error: ENOENT: no such file or directory, open 'missing.txt'
    at Object.openSync (node:fs:590:3)
    [Program crashes]

https://www.youtube.com/watch?v=NwoAZF66_Go

try-catch Basics

The try-catch statement lets you attempt risky operations and recover if they fail. Code in the try block runs normally until an error occurs. When an error happens, JavaScript immediately jumps to the catch block, skipping any remaining code in try.

try {
  // Code that might fail
  const content = fs.readFileSync('data.txt', 'utf8');
  const config = JSON.parse(content);
} catch (error) {
  // Handle the error
  console.log('Error:', error.message);
}

In this example, two things could go wrong: the file might not exist (readFileSync fails), or the file might contain invalid JSON (JSON.parse fails). Either error will be caught by the same catch block.

The key advantage: your program doesn't crash. Instead of terminating, it executes the error handling code and continues running afterward. This lets you provide fallback behavior, log the problem, or inform the user what went wrong.

Think of try-catch like a safety net. You attempt something risky in the try block, and if it fails, the catch block catches you before you hit the ground.

<aside> 💡

Only one error can be caught per try-catch. If multiple errors could occur, the first one that happens triggers the catch block and stops execution of the remaining try code.

</aside>

<aside> ⌨️

Hands on: Create a file data.txt with the text "Hello World". Write code to read it and convert the content to uppercase. Wrap the file reading in try-catch. Then intentionally misspell the filename and run the code again to see the error handling in action.

</aside>

The Error Object

When an error is caught, the catch block receives an error object containing detailed information about what went wrong. This object isn't just a string—it's a structured object with multiple properties that help you understand and handle the failure.

try {
  fs.readFileSync('missing.txt', 'utf8');
} catch (error) {
  console.log(error.message); // Description
  console.log(error.code);    // Error code (ENOENT, EACCES, etc.)
  console.log(error.name);    // Error type (Error, SyntaxError, etc.)
}

The message property gives you a human-readable description of what went wrong. The code property provides a specific error code, which is particularly useful for file operations where you need to distinguish between "file not found" versus "permission denied." The name property tells you the type of error that occurred, like SyntaxError for invalid JSON or TypeError for wrong data types.

These properties let you handle different errors differently. For example, if a config file doesn't exist (ENOENT), you might create a default one. But if you don't have permission to read it (EACCES), you'd show a different error message to the user.

Common file error codes:

<aside> 💡

The error object also has a stack property containing the full stack trace—useful for debugging but usually too technical to show to users.

</aside>

<aside> ⌨️

Hands on: Write code that tries to parse invalid JSON (e.g., JSON.parse('{invalid}')). In the catch block, log all three error properties: name, message, and stack. Notice how stack shows you exactly where the error occurred.

</aside>

Throwing Errors

While try-catch handles errors that JavaScript throws automatically, you can also create and throw your own errors. This is essential for validating user input and enforcing business rules in your code. When you throw an error, you're deliberately stopping execution and triggering the nearest catch block. Think of it as pulling an emergency brake: you've detected a problem and need to halt the normal flow.

function createUser(username, age) {
  if (!username || username.length < 3) {
    throw new Error('Username must be at least 3 characters');
  }
  if (age < 18) {
    throw new Error('Must be 18 or older');
  }
  return { username, age };
}

try {
  const user = createUser('Al', 16);
} catch (error) {
  console.log('Validation failed:', error.message);
}

In this example, createUser checks its inputs before doing anything with them. If the username is too short, it immediately throws an error and the function stops and never reaches the return statement. The calling code catches this error and handles it appropriately.

This pattern of "validate early, fail fast" prevents invalid data from spreading through your program. It's better to catch a problem at the boundary (when data enters your function) than to let it cause mysterious bugs later.

<aside> 💡

Always create errors with new Error('message') rather than throwing plain strings. Error objects include stack traces that show exactly where the problem occurred, making debugging much easier.

</aside>

<aside> ⌨️

Hands on: Write a validateEmail(email) function that throws an error if the email doesn't contain an '@' symbol. Test it by calling it inside a try-catch with both valid and invalid emails.

</aside>

Practical Patterns

Safe File Reading

Wrapping file operations in helper functions makes your code cleaner and more reusable. This pattern returns a default value when reading fails, allowing your program to continue with sensible fallbacks.

function safeReadFile(filename, defaultValue = '') {
  try {
    return fs.readFileSync(filename, 'utf8');
  } catch (error) {
    console.log(`Could not read ${filename}`);
    return defaultValue;
  }
}

const config = safeReadFile('config.txt', 'default config');

The caller doesn't need to write try-catch blocks everywhere: the function handles errors internally. If the file exists, you get its contents. If not, you get the default value. Either way, the program continues smoothly.

Load or Create Config

A common pattern is creating default files when they don't exist. This makes your application self-initializing: users don't need to manually create configuration files.

function loadConfig(filename) {
  try {
    const content = fs.readFileSync(filename, 'utf8');
    return JSON.parse(content);
  } catch (error) {
    if (error.code === 'ENOENT') {
      // Create default if missing
      const defaults = { theme: 'light', language: 'en' };
      fs.writeFileSync(filename, JSON.stringify(defaults, null, 2));
      return defaults;
    }
    throw error; // Re-throw unexpected errors
  }
}

Notice the throw error at the end. If the error is something other than "file not found" (like a permissions issue or invalid JSON), we re-throw it because we don't know how to handle it. This prevents hiding serious problems while still handling the common case of missing files.

Specific Error Handling

Different errors require different responses. By checking error properties, you can provide specific feedback for each failure type.

try {
  const data = fs.readFileSync('config.json', 'utf8');
  const config = JSON.parse(data);
} catch (error) {
  if (error.code === 'ENOENT') {
    console.log('File not found');
  } else if (error.name === 'SyntaxError') {
    console.log('Invalid JSON');
  } else {
    console.log('Unexpected error:', error.message);
  }
}

This handles three scenarios: file doesn't exist, file contains bad JSON, or something else went wrong. Each gets a tailored message. The final else catches anything unexpected (always include this fallback to handle errors you didn't anticipate).

The finally Block

The finally block runs after try-catch completes, regardless of whether an error occurred. It's perfect for cleanup operations that must happen no matter what.

try {
  const data = processFile('data.txt');
} catch (error) {
  console.log('Processing failed');
} finally {
  console.log('Cleanup complete');
  // Always runs
}

Think of finally as a ‘guarantee’: this code will execute whether the try block succeeds, fails, or even if you return early. Common uses include closing files, releasing resources, logging completion, or resetting state. If you don't need cleanup, you can omit the finally block entirely.

Best Practices

Be specific:

Vague error messages waste debugging time. Specific messages tell you exactly what's wrong and often how to fix it.

// Bad
throw new Error('Error');

// Good
throw new Error('Username must be 3-20 characters');

Include relevant details in your error messages: what was expected, what was received, and which specific rule was violated. Your future self (and your teammates) will thank you.

Don't ignore errors:

Empty catch blocks hide failures and create mysterious bugs. Always acknowledge errors, even if you can't fix them immediately.

// Bad - silent failure
try {
  saveData();
} catch (error) {}

// Good
try {
  saveData();
} catch (error) {
  console.log('Save failed:', error.message);
}

At minimum, log the error. Better yet, provide fallback behavior or inform the user. Silent failures are one of the hardest bugs to diagnose because there's no indication anything went wrong.

Validate early:

Check inputs at the start of functions before doing any work. This "fail fast" approach prevents bad data from propagating through your program.

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

Validation errors are easier to debug when they happen immediately at the function boundary rather than deep inside complex logic. This also makes your functions more predictable, so that callers know exactly what inputs are acceptable.

<aside> ⌨️

Hands on: Write saveUserData(filename, userData) that saves JSON. If it fails, create a backup file with timestamp. Test both success and failure.

</aside>

Additional Resources

Video:

Reading: