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:
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:
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:
throw new Error("Something went wrong");Error objects have several important properties:
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 informationBenefits of using Error objects:
- Standardization: All errors have the same interface (name, message, stack)
- Stack Trace: Automatically includes call stack information for debugging
- Type Checking: Can use
instanceofto determine error types - Tool Support: Debugging tools and logging systems better recognize Error objects
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:
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:
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:
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
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
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
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
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:
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:
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:
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:
// 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
// ❌ 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
// ❌ 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
// ❌ 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
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
// ❌ 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
throwstatement 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.