Skip to content

Throwing Custom Errors: Precise Error Control

When a chef discovers that ingredients have expired, they immediately stop cooking and issue a warning: "These ingredients cannot be used!" rather than continue cooking potentially contaminated food. In programming, the throw statement works like this warning mechanism—when code detects conditions that prevent it from continuing, it actively throws an error to let the caller know something went wrong, rather than silently producing incorrect results.

Throw Statement: Actively Throwing Errors

JavaScript automatically throws many types of errors (like accessing properties of undefined), but sometimes you need to actively throw errors based on business logic. This is where the throw statement comes in:

javascript
function divide(a, b) {
  if (b === 0) {
    throw "Cannot divide by zero!"; // Throw error
  }

  return a / b;
}

try {
  let result = divide(10, 0);
  console.log(result);
} catch (error) {
  console.log("Error occurred:", error);
}

// Output: Error occurred: Cannot divide by zero!

The throw statement immediately terminates the execution of the current function and passes control to the nearest catch block. If there's no catch block, the program will crash.

Technically, you can throw any type of value:

javascript
throw "This is a string error";
throw 42;
throw true;
throw { message: "Custom object", code: 404 };
throw new Error("This is an Error object");

But in practice, you should always throw Error objects or their subclasses, for reasons we'll explain shortly.

Error Objects: Standard Error Representation

JavaScript provides a built-in Error constructor to create standard error objects:

javascript
throw new Error("Something went wrong");

Error objects have several important properties:

javascript
let error = new Error("File not found");

console.log(error.name); // "Error"
console.log(error.message); // "File not found"
console.log(error.stack); // Call stack information

Benefits of using Error objects:

  1. Standardization: All errors have the same interface (name, message, stack)
  2. Stack Trace: Automatically includes call stack information for debugging
  3. Type Checking: Can use instanceof to determine error types
  4. Tool Support: Debugging tools and logging systems better recognize Error objects
javascript
function readFile(filename) {
  if (!filename) {
    throw new Error("Filename is required");
  }

  if (!fileExists(filename)) {
    throw new Error(`File not found: ${filename}`);
  }

  // Read file...
}

try {
  readFile("");
} catch (error) {
  console.log(error.name); // Error
  console.log(error.message); // Filename is required
  console.log(error.stack); // Complete call stack
}

Creating Custom Error Classes

While Error objects are useful, sometimes you need more specific error types to distinguish between different categories of errors. You can create custom errors by inheriting from the Error class:

javascript
class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

class NetworkError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = "NetworkError";
    this.statusCode = statusCode;
  }
}

// Use custom errors
function validateEmail(email) {
  if (!email) {
    throw new ValidationError("Email is required");
  }

  if (!email.includes("@")) {
    throw new ValidationError("Invalid email format");
  }

  return true;
}

function fetchData(url) {
  // Simulate network request
  if (url === "") {
    throw new NetworkError("URL cannot be empty", 400);
  }

  // Simulate server error
  throw new NetworkError("Server unavailable", 503);
}

Now you can adopt different handling strategies based on error types:

javascript
try {
  validateEmail("invalid-email");
} catch (error) {
  if (error instanceof ValidationError) {
    console.log("Validation failed:", error.message);
    // Show user-friendly prompt
  } else if (error instanceof NetworkError) {
    console.log("Network error:", error.message, "Status:", error.statusCode);
    // Retry or show network error prompt
  } else {
    console.log("Unknown error:", error);
    // Handle other errors
  }
}

Including More Context Information

Custom error classes can contain any information useful for debugging:

javascript
class DatabaseError extends Error {
  constructor(message, query, params) {
    super(message);
    this.name = "DatabaseError";
    this.query = query;
    this.params = params;
    this.timestamp = new Date();
  }

  toString() {
    return `${this.name}: ${this.message}
    Query: ${this.query}
    Params: ${JSON.stringify(this.params)}
    Time: ${this.timestamp.toISOString()}`;
  }
}

function executeQuery(query, params) {
  try {
    // Simulate database query
    throw new Error("Connection timeout");
  } catch (error) {
    throw new DatabaseError("Query execution failed", query, params);
  }
}

try {
  executeQuery("SELECT * FROM users WHERE id = ?", [123]);
} catch (error) {
  if (error instanceof DatabaseError) {
    console.log(error.toString());
    // Log detailed error information
  }
}

Real-World Application Scenarios

1. Input Validation

javascript
class InvalidInputError extends Error {
  constructor(field, value, reason) {
    super(`Invalid ${field}: ${reason}`);
    this.name = "InvalidInputError";
    this.field = field;
    this.value = value;
    this.reason = reason;
  }
}

function createUser(userData) {
  // Validate username
  if (!userData.username || userData.username.length < 3) {
    throw new InvalidInputError(
      "username",
      userData.username,
      "Username must be at least 3 characters"
    );
  }

  // Validate age
  if (userData.age < 18 || userData.age > 120) {
    throw new InvalidInputError(
      "age",
      userData.age,
      "Age must be between 18 and 120"
    );
  }

  // Validate email
  if (!userData.email || !userData.email.includes("@")) {
    throw new InvalidInputError(
      "email",
      userData.email,
      "Invalid email format"
    );
  }

  // Create user...
  return { id: 1, ...userData };
}

try {
  let user = createUser({
    username: "ab",
    age: 25,
    email: "[email protected]",
  });
  console.log("User created:", user);
} catch (error) {
  if (error instanceof InvalidInputError) {
    console.log(`Field ${error.field} is invalid: ${error.reason}`);
    // Show form error prompt
  }
}

2. API Error Handling

javascript
class APIError extends Error {
  constructor(message, statusCode, endpoint) {
    super(message);
    this.name = "APIError";
    this.statusCode = statusCode;
    this.endpoint = endpoint;
  }
}

class NotFoundError extends APIError {
  constructor(endpoint) {
    super("Resource not found", 404, endpoint);
    this.name = "NotFoundError";
  }
}

class UnauthorizedError extends APIError {
  constructor(endpoint) {
    super("Unauthorized access", 401, endpoint);
    this.name = "UnauthorizedError";
  }
}

async function apiRequest(endpoint, token) {
  if (!token) {
    throw new UnauthorizedError(endpoint);
  }

  // Simulate API call
  let response = { status: 404 };

  if (response.status === 404) {
    throw new NotFoundError(endpoint);
  }

  if (response.status === 401) {
    throw new UnauthorizedError(endpoint);
  }

  if (response.status >= 400) {
    throw new APIError("Request failed", response.status, endpoint);
  }

  return response.data;
}

try {
  let data = await apiRequest("/api/users/123", null);
} catch (error) {
  if (error instanceof UnauthorizedError) {
    console.log("Please log in");
    // Redirect to login page
  } else if (error instanceof NotFoundError) {
    console.log("Resource not found:", error.endpoint);
    // Show 404 page
  } else if (error instanceof APIError) {
    console.log(`API error (${error.statusCode}):`, error.message);
    // Show error prompt
  }
}

3. Business Logic Errors

javascript
class InsufficientFundsError extends Error {
  constructor(balance, amount) {
    super(`Insufficient funds: balance ${balance}, attempted ${amount}`);
    this.name = "InsufficientFundsError";
    this.balance = balance;
    this.amount = amount;
  }
}

class AccountLockedError extends Error {
  constructor(accountId, reason) {
    super(`Account ${accountId} is locked: ${reason}`);
    this.name = "AccountLockedError";
    this.accountId = accountId;
    this.reason = reason;
  }
}

function withdraw(account, amount) {
  if (account.locked) {
    throw new AccountLockedError(account.id, "Too many failed login attempts");
  }

  if (amount > account.balance) {
    throw new InsufficientFundsError(account.balance, amount);
  }

  if (amount <= 0) {
    throw new Error("Amount must be positive");
  }

  account.balance -= amount;
  return { newBalance: account.balance, amount };
}

let account = { id: "123", balance: 100, locked: false };

try {
  let result = withdraw(account, 150);
  console.log("Withdrawal successful:", result);
} catch (error) {
  if (error instanceof InsufficientFundsError) {
    console.log(
      `Cannot withdraw $${error.amount}. Current balance: $${error.balance}`
    );
    // Show insufficient funds prompt
  } else if (error instanceof AccountLockedError) {
    console.log(`Account locked: ${error.reason}`);
    // Show account lock information
  } else {
    console.log("Error:", error.message);
  }
}

4. Configuration and Initialization Errors

javascript
class ConfigurationError extends Error {
  constructor(key, value, expected) {
    super(`Invalid configuration for ${key}`);
    this.name = "ConfigurationError";
    this.key = key;
    this.value = value;
    this.expected = expected;
  }
}

function loadConfiguration(config) {
  if (!config.apiUrl) {
    throw new ConfigurationError("apiUrl", undefined, "Valid URL string");
  }

  if (typeof config.timeout !== "number" || config.timeout <= 0) {
    throw new ConfigurationError(
      "timeout",
      config.timeout,
      "Positive number (milliseconds)"
    );
  }

  if (!["development", "production"].includes(config.environment)) {
    throw new ConfigurationError(
      "environment",
      config.environment,
      "'development' or 'production'"
    );
  }

  return config;
}

try {
  let config = loadConfiguration({
    apiUrl: "https://api.example.com",
    timeout: -1000,
    environment: "staging",
  });
} catch (error) {
  if (error instanceof ConfigurationError) {
    console.error(`Configuration error: ${error.key}`);
    console.error(`  Current value: ${error.value}`);
    console.error(`  Expected: ${error.expected}`);
  }
}

Error Re-throwing

Sometimes you need to catch an error, perform certain operations (like logging), and then re-throw it for upper-level handling:

javascript
function processData(data) {
  try {
    return transform(data);
  } catch (error) {
    console.error("Transform failed at:", new Date());
    console.error("Input data:", data);

    throw error; // Re-throw original error
  }
}

try {
  processData(invalidData);
} catch (error) {
  console.log("Caught in outer try:", error.message);
}

You can also wrap errors to provide more context:

javascript
function loadUserData(userId) {
  try {
    let data = fetchFromDatabase(userId);
    return parseUserData(data);
  } catch (error) {
    // Wrap original error, providing more information
    throw new Error(`Failed to load user ${userId}: ${error.message}`);
  }
}

Or create new custom errors while preserving the original error:

javascript
class DataProcessingError extends Error {
  constructor(message, cause) {
    super(message);
    this.name = "DataProcessingError";
    this.cause = cause; // Save original error
  }
}

function processFile(filename) {
  try {
    let content = readFile(filename);
    return parseContent(content);
  } catch (error) {
    throw new DataProcessingError(`Failed to process file: ${filename}`, error);
  }
}

try {
  processFile("data.json");
} catch (error) {
  console.log(error.message); // Failed to process file: data.json
  console.log("Original error:", error.cause.message); // Original error message
}

Conditional Error Throwing

Not all error situations require throwing errors. Sometimes returning special values or using default values is more appropriate:

javascript
// Throw error: strict mode
function getUser(id) {
  let user = database.findUser(id);

  if (!user) {
    throw new Error(`User ${id} not found`); // Caller must handle
  }

  return user;
}

// Return null: lenient mode
function getUserOrNull(id) {
  let user = database.findUser(id);
  return user || null; // Caller can simply check
}

// Mixed strategy
function getUser(id, options = {}) {
  let user = database.findUser(id);

  if (!user) {
    if (options.throwOnNotFound) {
      throw new Error(`User ${id} not found`);
    }
    return null;
  }

  return user;
}

Choose which approach based on:

  • Throw error: When missing data is a serious, unexpected situation
  • Return null/undefined: When missing data is a normal, expected situation
  • Return default value: When reasonable fallback can be provided

Common Pitfalls and Best Practices

1. Always Use Error Objects

javascript
// ❌ Throwing strings
throw "Something went wrong";

// ❌ Throwing plain objects
throw { message: "Error", code: 500 };

// ✅ Using Error objects
throw new Error("Something went wrong");

// ✅ Or custom Error classes
throw new CustomError("Something went wrong");

2. Provide Descriptive Error Messages

javascript
// ❌ Vague error messages
throw new Error("Invalid input");

// ✅ Specific error messages
throw new Error("Email address must contain @ symbol");

// ✅ Include context
throw new Error(`User ${userId} not found in database`);

3. Don't Swallow Errors

javascript
// ❌ Do nothing after catching
try {
  riskyOperation();
} catch (error) {
  // Error is swallowed
}

// ✅ At least log the error
try {
  riskyOperation();
} catch (error) {
  console.error("Operation failed:", error);
}

// ✅ Or re-throw
try {
  riskyOperation();
} catch (error) {
  console.error("Operation failed:", error);
  throw error;
}

4. Use Meaningful Error Names

javascript
class UserNotFoundError extends Error {
  constructor(userId) {
    super(`User ${userId} not found`);
    this.name = "UserNotFoundError"; // Clear error name
    this.userId = userId;
  }
}

5. Don't Overuse Custom Errors

javascript
// ❌ Creating a class for every small error
class UsernameEmptyError extends Error {}
class UsernameTooShortError extends Error {}
class UsernameHasSpacesError extends Error {}
class UsernameHasSpecialCharsError extends Error {}

// ✅ Use a general class, distinguish by properties
class ValidationError extends Error {
  constructor(field, reason) {
    super(`${field} validation failed: ${reason}`);
    this.name = "ValidationError";
    this.field = field;
    this.reason = reason;
  }
}

throw new ValidationError("username", "cannot be empty");
throw new ValidationError("username", "must be at least 3 characters");

Summary

Actively throwing and customizing errors are important techniques for building robust applications. Through the throw statement, you can immediately stop execution when problems are detected; through custom Error classes, you can create error objects with rich context, making error handling more precise and easier to debug.

Key takeaways:

  • Use throw statement to actively throw errors
  • Always throw Error objects or their subclasses, not strings or plain objects
  • Create custom Error classes to represent specific types of errors
  • Include useful context information in error objects
  • Provide clear, descriptive error messages
  • Choose between throwing errors or returning default values based on the situation
  • Can catch, log, and then re-throw errors
  • Don't overuse custom error classes; maintain reasonable granularity

Good error design makes code more maintainable, debugging easier, and user experience more friendly. Investing time in designing good error handling strategies is an important investment in code quality.