Programming Fundamentals

Error Handling and Debugging

3 min read
Focus: PROGRAMMING-FUNDAMENTALS

TL;DR — Quick Summary

  • Always throw Error instances (or subclasses) — never throw strings or plain objects — they include a stack trace.
  • Use finally for cleanup code that must run regardless of success or failure (hiding loaders, closing connections).
  • Never silently swallow errors with an empty catch block — at minimum, log them.
  • Re-throw errors you can't handle: if (!(err instanceof KnownError)) throw err — let unexpected errors bubble up.

Lesson Overview

Errors are inevitable in software. The difference between professional and amateur code is not the absence of errors — it's how they're anticipated, handled gracefully, and communicated clearly to both developers and users.

JavaScript has two types of errors: syntax errors (caught at parse time), and runtime errors (occur during execution). Runtime errors can be caught with try/catch. Unhandled errors crash programs, leak sensitive information, and create terrible user experiences.

Good error handling means: using descriptive error messages, creating custom error classes for domain-specific failures, and never silently swallowing errors.

Conceptual Deep Dive

try/catch/finally:

  • try: code that might throw
  • catch(err): runs if an error is thrown; err.message, err.name, err.stack
  • finally: always runs regardless of error — use for cleanup (closing connections, hiding loaders)

Built-in error types:

  • Error: generic
  • TypeError: wrong type (calling non-function, accessing property of null)
  • ReferenceError: variable not defined
  • SyntaxError: invalid syntax
  • RangeError: value out of valid range

Throwing errors: throw any value, but always throw Error instances or subclasses — they include a stack trace. Never throw strings.

Custom error classes: extend Error to create domain-specific errors (ValidationError, AuthError, NotFoundError) — allows precise catch filtering with instanceof.

Implementation Lab

try/catch/finally and Error Types
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Basic try/catch
function divideNumbers(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new TypeError('Both arguments must be numbers');
  }
  if (b === 0) {
    throw new RangeError('Division by zero is not allowed');
  }
  return a / b;
}
 
try {
  const result = divideNumbers(10, 0);
  console.log(result);
} catch (err) {
  if (err instanceof RangeError) {
    console.error('Math error:', err.message); // 'Division by zero is not allowed'
  } else if (err instanceof TypeError) {
    console.error('Type error:', err.message);
  } else {
    throw err; // Re-throw unknown errors — don't swallow them!
  }
}
 
// finally: always runs (cleanup, loaders, connections))
async function fetchUserData(userId) {
  showLoadingSpinner();
  try {
    const response = await fetch(`/api/users/${userId}`}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`{response.status}: ${response.statusText}`);
    }
    return await response.json();
  } catch (err) {
    console.error('Failed to fetch user:', err);
    showErrorMessage(err.message);
    return null;
  } finally {
    hideLoadingSpinner(); // Always hides spinner — success or failure
  }
}
Custom Error Classes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Custom error classes for domain-specific errorsfor domain-specific errors
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.name = this.constructor.name; // 'ValidationError', 'AuthError', etc.'AuthError', etc.
    this.statusCode = statusCode;
    Error.captureStackTrace(this, this.constructor); // clean stack trace
  }
}
 
class ValidationError extends AppError {
  constructor(field, message) {
    super(`Validation failed for '${field}': ${message}`for '${field}': ${message}`, 400);
    this.field = field;
  }
}
 
class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} with id '${id}' not found`} with id '${id}' not found`, 404);
  }
}
 
class AuthError extends AppError {
  constructor(message = 'Unauthorized') {
    super(message, 401);
  }
}
 
// Usage: precise error handling by type
function registerUser({ email, password, age }) {
  if (!email.includes('@')) {
    throw new ValidationError('email', 'Must be a valid email address');
  }
  if (password.length < 8) {
    throw new ValidationError('password', 'Must be at least 8 characters'8 characters');
  }
  if (age < 13) {
    throw new ValidationError('age', 'Must be at least 13 years old'13 years old');
  }
}
 
try {
  registerUser({ email: 'bad-email', password: 'abc', age: 10 });
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(`Field '${err.field}': ${err.message}`{err.field}': ${err.message}`);
    // Highlight the specific invalid field in the UIUI
  } else {
    throw err; // Re-throw unexpected errors
  }
}

Pro Tips — Senior Dev Insights

1

Create a hierarchy of custom errors (AppErrorValidationError, AuthError, NotFoundError) — this lets you catch broad categories while still handling specifics.

2

For async error handling, always await inside try/catch — promises rejected outside try won't be caught. Use Promise.allSettled when you want results even if some fail.

3

Use err.stack in server-side logging — it gives you the full call stack and makes debugging production issues far faster.

Common Developer Pitfalls

!
Empty catch blocks: catch(err) {} — silently swallows errors, making debugging impossible. Always at minimum log the error.
!
Throwing strings: throw 'Something went wrong' — strings have no stack trace and can't be caught with instanceof. Always throw new Error('message').
!
Forgetting that finally overrides return — a return in finally replaces any return value from try or catch. Avoid return in finally.
!

Not differentiating error types in catch — catching all errors the same way means validation errors get treated the same as server crashes.

Interview Mastery

return sends a value back to the caller and resumes normal execution. throw creates an exception that immediately unwinds the call stack, skipping any remaining code, until it hits a try/catch or reaches the top level (crashing the program or triggering the global error handler). Use return for expected outcomes (including returning null for 'not found'). Use throw for unexpected failures that the current code can't handle — errors that should interrupt the normal execution flow.

Custom error classes extend Error, which means: (1) they include a stack trace, essential for debugging; (2) they can be caught selectively with instanceof — catch (err) { if (err instanceof ValidationError) }; (3) they can carry additional properties (field name, HTTP status code, error code); (4) they have a name property for logging. Thrown strings are just strings — no stack trace, no type information, can only be caught generically.

finally runs after try and catch, regardless of whether an error was thrown or caught. Use it for cleanup that must always happen: hiding loading spinners, closing database connections, releasing file locks, clearing timeouts. Important quirk: a return statement inside finally overrides any return from try or catch — avoid return in finally. The pattern is: try { do work } catch { handle error } finally { always clean up }.

Wrap await calls in try/catch: try { const data = await fetch(url); } catch (err) { handle err }. Async functions return rejected promises on unhandled throws. For multiple parallel operations: await Promise.allSettled([...]) returns all results (fulfilled and rejected), unlike Promise.all which rejects immediately on first failure. Chain a .catch() on the async function call site for a global handler. Never leave unhandled promise rejections — they cause process crashes in Node.js.

Hands-on Lab Exercises

1
Create a custom error hierarchy: AppErrorValidationError, NetworkError, AuthError. Write a function that throws each type and a handler that responds differently to each.
2

Wrap a series of failing operations in try/catch/finally — verify that finally always runs even when different types of errors are thrown.

3
Write a safeJSON function that parses a JSON string and returns null on failure instead of throwing.
4
Build a retry function: retry(fn, times) that calls fn up to times times, only retrying on NetworkError, and re-throwing other error types.

Real-World Practice Scenarios

Form validation: Throw ValidationError for each invalid field with the field name and message. Catch them in the UI to highlight the specific field.
API client: Throw specific errors for HTTP status codes (401 → AuthError, 404 → NotFoundError, 500 → ServerError). Use finally to hide the loading state.
File processing: Read and parse multiple files — use try/catch per file so one failure doesn't stop processing the rest.
Database operations: Wrap queries in try/catch, use finally to always release the connection, and log full stack traces for unexpected errors.

Deepen Your Knowledge