Custom Errors & Error Types

Difficulty: Intermediate

JavaScript provides several built-in error types, each representing a specific category of problem. Understanding these types helps you diagnose issues faster and write more targeted error handling. The base Error class is the parent of all error types, and each subclass communicates a different kind of failure to both developers and error-handling code.

TypeError is the most common error type in JavaScript. It occurs when a value is not of the expected type - for example, calling a non-function, accessing properties on null or undefined, or passing the wrong argument type to a built-in method. ReferenceError fires when you try to use a variable that hasn't been declared. SyntaxError typically appears at parse time when the JavaScript engine encounters malformed code, though it can also occur at runtime with functions like JSON.parse() or eval(). RangeError indicates a numeric value is outside its allowed range, like creating an array with a negative length or calling toFixed() with too many digits. URIError is rare and only occurs when URI handling functions like decodeURIComponent() receive malformed input.

Creating custom error classes is essential for building maintainable applications. By extending the built-in Error class, you create domain-specific error types that make your error handling more precise and self-documenting. A custom error class can carry additional properties like HTTP status codes, error codes, or user-facing messages, making it easier for catch blocks to decide how to respond.

The key to a proper custom error class is calling super(message) in the constructor to set the message property, and setting the name property to match your class name. Without setting name, your error will show up as 'Error' in stack traces instead of your custom class name, making debugging harder. You should also ensure the prototype chain is correct so that instanceof checks work properly.

Building an error hierarchy allows you to catch errors at different levels of specificity. For example, you might have a base AppError, with subclasses like ValidationError, AuthenticationError, and NotFoundError. A catch block can check for the most specific type first and fall back to broader types, providing appropriate responses at each level.

The instanceof operator is the standard way to check error types in catch blocks. It works with both built-in and custom error types and respects the inheritance chain, so catching a parent error type also catches all its subclasses. This makes hierarchical error handling clean and maintainable, and it's far more reliable than checking the error's name string property.

Code examples

Built-in error types in action

// TypeError - wrong type operation
try {
  const num = null;
  num.toString();
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}

// ReferenceError - undeclared variable
try {
  console.log(undeclaredVar);
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}

// RangeError - value out of range
try {
  const arr = new Array(-1);
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}

// URIError - malformed URI
try {
  decodeURIComponent('%');
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}

Each built-in error type signals a specific category of problem. Recognizing these types helps you understand what went wrong without even reading the message.

Creating custom error classes

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = 'AppError';
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

class ValidationError extends AppError {
  constructor(message, fields = []) {
    super(message, 400);
    this.name = 'ValidationError';
    this.fields = fields;
  }
}

class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, 404);
    this.name = 'NotFoundError';
    this.resource = resource;
  }
}

class AuthError extends AppError {
  constructor(message = 'Authentication required') {
    super(message, 401);
    this.name = 'AuthError';
  }
}

// Usage
const err = new ValidationError('Invalid input', ['email', 'password']);
console.log(err.name);
console.log(err.message);
console.log(err.statusCode);
console.log(err.fields);
console.log(err instanceof ValidationError);
console.log(err instanceof AppError);
console.log(err instanceof Error);

Custom errors extend the Error class and can carry domain-specific data. The inheritance chain works correctly - a ValidationError is also an AppError and an Error, making hierarchical catch blocks possible.

Error hierarchy in catch blocks

class AppError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'AppError';
    this.code = code;
  }
}

class DatabaseError extends AppError {
  constructor(message, query) {
    super(message, 'DB_ERROR');
    this.name = 'DatabaseError';
    this.query = query;
  }
}

class ConnectionError extends DatabaseError {
  constructor(host) {
    super(`Cannot connect to ${host}`, null);
    this.name = 'ConnectionError';
    this.host = host;
  }
}

function handleError(error) {
  if (error instanceof ConnectionError) {
    console.log(`Connection failed to ${error.host} - retrying...`);
  } else if (error instanceof DatabaseError) {
    console.log(`DB error on query: ${error.query}`);
  } else if (error instanceof AppError) {
    console.log(`App error [${error.code}]: ${error.message}`);
  } else {
    console.log(`Unknown error: ${error.message}`);
  }
}

handleError(new ConnectionError('db.example.com'));
handleError(new DatabaseError('Query failed', 'SELECT * FROM users'));
handleError(new AppError('Something broke', 'GENERIC'));
handleError(new Error('Random error'));

The instanceof checks go from most specific to most general. A ConnectionError matches the first check even though it's also a DatabaseError and AppError. Order matters - putting AppError first would catch everything.

Practical error handling pattern

class ValidationError extends Error {
  constructor(errors) {
    super('Validation failed');
    this.name = 'ValidationError';
    this.errors = errors;
  }
}

function validateUser(user) {
  const errors = [];
  
  if (!user.name || user.name.trim().length < 2) {
    errors.push({ field: 'name', message: 'Name must be at least 2 characters' });
  }
  if (!user.email || !user.email.includes('@')) {
    errors.push({ field: 'email', message: 'Valid email is required' });
  }
  if (!user.age || user.age < 0 || user.age > 150) {
    errors.push({ field: 'age', message: 'Age must be between 0 and 150' });
  }
  
  if (errors.length > 0) {
    throw new ValidationError(errors);
  }
  
  return { valid: true, user };
}

try {
  validateUser({ name: 'A', email: 'bad', age: -5 });
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(error.message);
    error.errors.forEach(e => {
      console.log(`  ${e.field}: ${e.message}`);
    });
  }
}

This real-world pattern collects all validation errors before throwing a single ValidationError with structured error data. The catch block can then display field-specific messages to the user.

Key points

Concepts covered

TypeError, ReferenceError, SyntaxError, RangeError, custom error classes, instanceof, error hierarchy