Errors and Control Flow
What are errors?
Errors are objects that represent something going wrong. In JavaScript, errors also affect control flow: when an error is thrown, normal execution stops and JavaScript looks for error-handling code.
const error = new Error("Something went wrong");
console.log(error.message); // "Something went wrong"
You usually throw errors when your code cannot continue safely, and you catch errors when you can respond to the problem.
Why this matters
Real programs deal with missing data, invalid input, network failures, broken JSON, and unexpected states. Error handling lets you fail clearly, recover when possible, and avoid silent bugs.
Good error handling answers three questions:
- What went wrong?
- Where can this be handled?
- Should the program recover, return a fallback, or stop?
Common error types
JavaScript has several built-in error types.
ReferenceError
A ReferenceError happens when you use a variable that does not exist:
// This would cause a ReferenceError:
// console.log(missingValue);
TypeError
A TypeError happens when a value is not the type or shape your code expected:
const user = null;
// This would cause a TypeError:
// console.log(user.name);
SyntaxError
A SyntaxError happens when JavaScript code is not valid syntax. These are usually caught before the code runs:
// This would cause a SyntaxError:
// if (true {
// console.log("missing parenthesis");
// }
You can also get a SyntaxError at runtime from code that parses text, such as JSON.parse():
try {
JSON.parse("{ bad json }");
} catch (error) {
console.log(error.name); // "SyntaxError"
}
RangeError
A RangeError happens when a value is outside an allowed range:
// This would cause a RangeError:
// const items = new Array(-1);
Throwing errors
Use throw when a function cannot continue safely:
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
try {
console.log(divide(10, 0));
} catch (error) {
console.log(error.message); // "Cannot divide by zero"
}
Throwing an error stops the function immediately:
function getUserName(user) {
if (!user) {
throw new Error("User is required");
}
return user.name;
}
The return line only runs if user exists.
try / catch
Use try/catch when code might throw and you can handle the failure:
try {
const data = JSON.parse("{ bad json }");
console.log(data);
} catch (error) {
console.log("Could not parse JSON:", error.message);
}
console.log("Program continues");
Output:
Could not parse JSON: Expected property name or '}' in JSON at position 2
Program continues
The exact error message can vary by JavaScript environment.
What happens:
- JavaScript tries to run the code in
try. JSON.parse()throws an error.- JavaScript jumps to
catch. - Code after the
try/catchcontinues.
The error object
The catch block receives the thrown error:
try {
JSON.parse("{ bad json }");
} catch (error) {
console.log(error.name); // "SyntaxError"
console.log(error.message); // Error message
}
Common properties include:
name: the error type namemessage: the human-readable messagestack: where the error happened
stack is useful for debugging, but it is usually too detailed for beginner-facing output.
Catching specific errors
Use instanceof when you need different handling for different error types:
try {
JSON.parse("{ bad json }");
} catch (error) {
if (error instanceof SyntaxError) {
console.log("Invalid JSON");
} else {
throw error;
}
}
If you cannot handle an error, rethrow it. Do not silently swallow it.
finally
A finally block always runs, whether an error happened or not:
function runOperation() {
throw new Error("Network failed");
}
let isLoading = true;
try {
runOperation();
} catch (error) {
console.log("Operation failed:", error.message);
} finally {
isLoading = false;
}
console.log(isLoading); // false
Use finally for cleanup: resetting loading state, releasing resources, closing connections, or clearing temporary state.
Error propagation
If an error is not caught, it moves up the call stack:
function level1() {
level2();
}
function level2() {
level3();
}
function level3() {
throw new Error("Failed in level3");
}
try {
level1();
} catch (error) {
console.log(error.message); // "Failed in level3"
}
The error starts in level3, moves through level2 and level1, and is caught by the catch block.
If no code catches the error, the program usually stops or logs the error to the console.
Errors vs normal control flow
Errors are useful for exceptional cases. Do not use them for ordinary decisions that are easy to check first.
Avoid this:
function getItem(array, index) {
try {
return array[index].name;
} catch (error) {
return null;
}
}
Prefer checking first:
function getItem(array, index) {
const item = array[index];
if (!item) {
return null;
}
return item.name;
}
Use if statements for expected branches. Use errors when something violates the assumptions your code needs to continue.
Throwing vs returning a fallback
Throw an error when the caller needs to know that the operation failed.
Return a fallback when the missing or invalid value is expected and easy to handle locally.
function getRequiredUserName(user) {
if (!user) {
throw new Error("User is required");
}
return user.name;
}
This function throws because it cannot do its job without a user.
function getDisplayName(user) {
if (!user) {
return "Guest";
}
return user.name;
}
This function returns a fallback because "Guest" is a valid answer.
Custom errors
You can create custom error classes when a specific kind of failure matters:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
function validateEmail(email) {
if (!email.includes("@")) {
throw new ValidationError("Email must include @");
}
}
Custom errors are most useful in larger applications where callers need to respond differently to different failures.
Async errors
Async code needs its own error handling. With async / await, use try/catch:
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Failed to load user:", error.message);
throw error;
}
}
Important: fetch() rejects for network failures, but it does not reject for HTTP error statuses like 404 or 500. Check response.ok when those statuses should count as errors.
With promises, use .catch():
fetch("/api/users/1")
.then((response) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
})
.then((user) => console.log(user))
.catch((error) => {
console.error("Failed to load user:", error.message);
});
Async patterns are covered in more detail in the async guide.
Common patterns
Parsing JSON safely
function parseJSONOrNull(text) {
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
console.log(parseJSONOrNull('{"name":"Alice"}')); // { name: "Alice" }
console.log(parseJSONOrNull("{ bad json }")); // null
This is a good use of try/catch because invalid JSON is an expected possibility.
Validating inputs early
function createUser(name, email) {
if (!name) {
throw new Error("Name is required");
}
if (!email.includes("@")) {
throw new Error("Email must include @");
}
return { name, email };
}
Clear validation errors make bugs easier to find.
Handling and rethrowing
function saveSettings(settings) {
if (!settings.theme) {
throw new Error("Theme is required");
}
}
const settings = {};
try {
saveSettings(settings);
} catch (error) {
console.error("Could not save settings:", error.message);
throw error;
}
Log or add context when useful, then rethrow if this part of the program cannot recover.
Best practices
- Throw errors when code cannot continue safely.
- Catch errors where you can respond: recover, show a message, retry, or return a fallback.
- Do not swallow errors silently: it hides bugs.
- Use descriptive messages: include what was expected and what went wrong.
- Validate inputs early: fail before doing deeper work.
- Return fallbacks for expected missing values when a fallback is a valid result.
- Use normal conditionals for expected branches: do not use
try/catchas a replacement forif. - Check
response.okwithfetch()when HTTP error statuses should count as failures. - Rethrow errors you cannot handle.
- Use
finallyfor cleanup when cleanup must happen either way.
Summary
Errors represent failures and change control flow. Use throw when a function cannot continue, try/catch when you can handle a failure, and finally when cleanup must always run. Catch errors at the level that can respond meaningfully, avoid swallowing them silently, and use ordinary conditionals for normal expected decisions.