Error Handling
12 min read
Chapter 9: Error Handling
How your code handles failure is as important as how it handles success.
Software fails. Files do not exist. Networks time out. Users enter invalid input. Memory runs out. A parser encounters a string that is not a valid integer. Every function you write has a contract — inputs it expects, outputs it promises — and the question is what happens when that contract is not met.
C++ gives you several mechanisms. This chapter covers all of them, when each is appropriate, and how to combine them cleanly.
9.1 Return codes versus exceptions
The two traditional schools:
Return codes: the function returns a value indicating success or failure. The caller must check it. If the caller does not check, the error is silently ignored — which is usually worse than the error itself.
Exceptions: the function throws an exception on failure. The exception propagates up the call stack until something catches it. If nothing catches it, std::terminate is called and the program aborts. The caller does not need to check for errors at every call site, but it must handle exceptions somewhere.
Both have legitimate uses. Here is the same problem solved both ways:
#include <iostream>
#include <string>
#include <stdexcept>
#include <climits>
// ---- Return code style ----
enum class ParseError { Ok, EmptyString, InvalidChar, Overflow };
ParseError parseIntRC(const std::string& s, int& out) {
if (s.empty()) return ParseError::EmptyString;
size_t pos = 0;
long val;
try {
val = std::stol(s, &pos);
} catch (...) {
return ParseError::InvalidChar;
}
if (pos != s.size()) return ParseError::InvalidChar;
if (val < INT_MIN || val > INT_MAX) return ParseError::Overflow;
out = static_cast<int>(val);
return ParseError::Ok;
}
// ---- Exception style ----
int parseIntEx(const std::string& s) {
if (s.empty()) throw std::invalid_argument{"Empty string"};
size_t pos = 0;
long val = std::stol(s, &pos); // throws std::invalid_argument or std::out_of_range
if (pos != s.size()) throw std::invalid_argument{"Not an integer: " + s};
if (val < INT_MIN || val > INT_MAX) throw std::out_of_range{"Out of int range: " + s};
return static_cast<int>(val);
}
int main() {
// Return code style
int result;
auto rc = parseIntRC("123", result);
if (rc == ParseError::Ok) {
std::cout << "Parsed: " << result << "\n";
} else {
std::cout << "Parse failed\n";
}
// Exception style
try {
int val1 = parseIntEx("456");
std::cout << "Parsed: " << val1 << "\n";
int val2 = parseIntEx("abc"); // throws
std::cout << "This won't print: " << val2 << "\n";
} catch (const std::invalid_argument& e) {
std::cerr << "Parse error: " << e.what() << "\n";
} catch (const std::out_of_range& e) {
std::cerr << "Range error: " << e.what() << "\n";
}
return 0;
}
Neither style is universally correct. Sections 9.6 gives guidelines for choosing.
9.2 Throwing and catching exceptions
The mechanics: throw any value; catch by type. Catching by const reference is the right choice — it avoids copying and prevents slicing.
#include <iostream>
#include <string>
#include <stdexcept>
#include <vector>
// A custom exception — derive from std::exception or one of its subclasses
class DatabaseError : public std::runtime_error {
public:
DatabaseError(const std::string& message, int errorCode)
: std::runtime_error{message}, errorCode_{errorCode} {}
int errorCode() const { return errorCode_; }
private:
int errorCode_;
};
void connectToDatabase(const std::string& host) {
if (host.empty()) {
throw std::invalid_argument{"Host cannot be empty"};
}
if (host == "unreachable-host") {
throw DatabaseError{"Connection refused to " + host, 111};
}
std::cout << "Connected to " << host << "\n";
}
int main() {
std::vector<std::string> hosts{"good-host", "", "unreachable-host", "another-good-host"};
for (const auto& host : hosts) {
try {
connectToDatabase(host);
} catch (const DatabaseError& e) {
// Catch DatabaseError first — it is more specific
std::cerr << "DB error [" << e.errorCode() << "]: " << e.what() << "\n";
} catch (const std::invalid_argument& e) {
std::cerr << "Argument error: " << e.what() << "\n";
} catch (const std::exception& e) {
// Catch-all for any std::exception
std::cerr << "Unknown error: " << e.what() << "\n";
}
// Note: no catch (...) — we want unknown exceptions to propagate
}
return 0;
}
The C++ exception hierarchy (relevant subset):
std::exception
├── std::logic_error
│ ├── std::invalid_argument
│ ├── std::out_of_range
│ └── std::domain_error
└── std::runtime_error
├── std::overflow_error
├── std::underflow_error
└── std::system_error
Rules:
- Throw by value (not by pointer).
- Catch by
const reference(prevents copying, prevents slicing). - Catch more specific types before more general ones.
- Always have a
std::exceptioncatch as a fallback. - Do not use
catch (...)to silently ignore all errors — you will hide bugs. - Always derive custom exceptions from
std::exceptionor one of its subclasses.
9.3 Writing exception-safe code
Exception-safe code has three levels:
- Basic guarantee: if an exception is thrown, the program remains in a valid state. No leaks, no corruption.
- Strong guarantee: if an exception is thrown, the operation has no effect — rollback semantics.
- Nothrow guarantee: the operation never throws. Functions marked
noexceptshould satisfy this.
RAII (Chapter 6) provides the basic guarantee almost automatically: if an exception is thrown, destructors of local objects still run, and resources are released.
#include <iostream>
#include <string>
#include <stdexcept>
class Transaction {
public:
Transaction() : committed_{false} {
std::cout << "Transaction started\n";
}
~Transaction() {
if (!committed_) {
std::cout << "Transaction rolled back (destructor)\n";
}
}
void commit() {
committed_ = true;
std::cout << "Transaction committed\n";
}
private:
bool committed_;
};
void processOrder(int orderId, bool simulateFailure) {
Transaction tx; // RAII: destructor rolls back if not committed
std::cout << "Processing order " << orderId << "\n";
if (simulateFailure) {
throw std::runtime_error{"Payment gateway timeout"};
// tx destructor runs here — rollback
}
// Do more work...
tx.commit();
}
int main() {
try {
processOrder(101, false); // succeeds
} catch (const std::exception& e) {
std::cerr << "Order 101 failed: " << e.what() << "\n";
}
try {
processOrder(102, true); // fails — tx is rolled back
} catch (const std::exception& e) {
std::cerr << "Order 102 failed: " << e.what() << "\n";
}
return 0;
}
The Transaction destructor runs even when the exception is thrown. That is RAII providing the basic guarantee. The rolled-back state is well-defined (no partial commits).
Important: destructors must not throw. If a destructor throws while the stack is already being unwound (during exception handling), std::terminate is called. Mark destructors noexcept (which they are by default in C++11 and later).
9.4 std::optional
std::optional<T> represents "either a value of type T, or nothing." It is the right tool when absence of a value is a normal, expected condition — not an error.
#include <iostream>
#include <optional>
#include <string>
#include <map>
std::optional<int> findAge(const std::string& name) {
static const std::map<std::string, int> db{
{"Alice", 30}, {"Bob", 25}, {"Carol", 35}
};
auto it = db.find(name);
if (it == db.end()) return std::nullopt;
return it->second;
}
std::optional<int> parsePositiveInt(const std::string& s) {
if (s.empty()) return std::nullopt;
for (char c : s) {
if (!std::isdigit(static_cast<unsigned char>(c))) return std::nullopt;
}
int val = std::stoi(s);
return val > 0 ? std::optional<int>{val} : std::nullopt;
}
int main() {
// Check with has_value()
auto age = findAge("Alice");
if (age.has_value()) {
std::cout << "Alice is " << age.value() << "\n";
}
// Implicit bool conversion
auto unknownAge = findAge("Dave");
if (unknownAge) {
std::cout << "Dave is " << *unknownAge << "\n";
} else {
std::cout << "Dave not found\n";
}
// value_or: default if absent
std::cout << "Eve's age: " << findAge("Eve").value_or(-1) << "\n"; // -1
// Parsing
auto good = parsePositiveInt("42");
auto bad = parsePositiveInt("hello");
auto zero = parsePositiveInt("0");
std::cout << "42: " << good.value_or(-1) << "\n"; // 42
std::cout << "hello: " << bad.value_or(-1) << "\n"; // -1
std::cout << "0: " << zero.value_or(-1) << "\n"; // -1
// Modifying through optional
if (auto result = findAge("Bob")) {
*result += 1; // modify the copy (original is unchanged)
std::cout << "Bob's age next year: " << *result << "\n";
}
return 0;
}
Never call .value() on an empty optional — it throws std::bad_optional_access. Always check first with if (opt) or has_value(), or use value_or(default).
9.5 std::expected (C++23)
std::expected<T, E> holds either a value of type T (success) or an error of type E (failure). It is like optional but carries the reason for failure, and unlike exceptions it keeps the error information in the return value.
#include <iostream>
#include <expected> // C++23
#include <string>
#include <vector>
enum class ParseError {
EmptyInput,
InvalidCharacter,
Overflow,
TrailingChars
};
std::string errorMessage(ParseError e) {
switch (e) {
case ParseError::EmptyInput: return "Empty input";
case ParseError::InvalidCharacter:return "Invalid character";
case ParseError::Overflow: return "Value overflow";
case ParseError::TrailingChars: return "Trailing characters after number";
}
return "Unknown error";
}
std::expected<int, ParseError> parseInteger(const std::string& s) {
if (s.empty()) return std::unexpected{ParseError::EmptyInput};
size_t pos = 0;
long val;
try {
val = std::stol(s, &pos);
} catch (const std::out_of_range&) {
return std::unexpected{ParseError::Overflow};
} catch (...) {
return std::unexpected{ParseError::InvalidCharacter};
}
if (pos != s.size()) return std::unexpected{ParseError::TrailingChars};
if (val < INT_MIN || val > INT_MAX) return std::unexpected{ParseError::Overflow};
return static_cast<int>(val);
}
int main() {
std::vector<std::string> inputs{"42", "", "abc", "99999999999", "12abc"};
for (const auto& input : inputs) {
auto result = parseInteger(input);
if (result) {
std::cout << "'" << input << "' -> " << *result << "\n";
} else {
std::cout << "'" << input << "' -> Error: " << errorMessage(result.error()) << "\n";
}
}
// value_or
auto n = parseInteger("42");
std::cout << "Value or default: " << n.value_or(-1) << "\n";
return 0;
}
std::expected requires C++23 (-std=c++23). Check your compiler version: GCC 12+, Clang 16+, and MSVC 2022 17.5+ support it.
The advantage over std::optional: the error carries information. The advantage over exceptions: the error is part of the function's return type — callers must consciously decide what to do with it.
9.6 When to use which strategy
Here is a practical guide:
Exceptions: for unexpected failures that callers cannot easily anticipate or check for. A file that should exist but does not. A network connection that drops. Running out of memory. Logic errors that indicate a programming mistake (not user input error). The overhead of exception handling (when no exception is thrown) is zero in most ABIs.
std::optional<T>: when absence of a value is a normal outcome. Searching for an element that may not exist. Parsing that might fail on invalid input where failure is routine. Do not use exceptions for "I searched and found nothing."
std::expected<T, E>: when the operation might fail and you need to tell the caller why. Richer than optional, cheaper than exceptions (no stack unwinding), and makes the error type part of the API contract.
Return codes (enum or int): in performance-critical code where exceptions are banned (embedded systems, some game engines, safety-critical code). Also when interfacing with C APIs. Modern C++ code generally avoids this in favor of optional or expected.
assert: for preconditions that are programming mistakes — things that should never happen in correct code. assert(index < size) in a function that expects a valid index. Asserts are removed in release builds and are not for user input validation.
#include <iostream>
#include <optional>
#include <cassert>
#include <stdexcept>
#include <string>
// Use optional: "might not find anything" is a normal outcome
std::optional<std::string> findUserEmail(int userId) {
if (userId == 1) return "[email protected]";
return std::nullopt; // not found — normal, not an error
}
// Use exception: file not found is unexpected and unrecoverable here
std::string readConfig(const std::string& path) {
// In a real program, open the file and read it
if (path.empty()) throw std::runtime_error{"Config path cannot be empty"};
return "config_data";
}
// Use assert: programming contract violation
int getElement(const std::vector<int>& v, size_t index) {
assert(index < v.size()); // bug if this fires — remove in release
return v[index];
}
int main() {
auto email = findUserEmail(1);
if (email) std::cout << "Email: " << *email << "\n";
else std::cout << "User not found\n";
try {
std::string config = readConfig("settings.json");
std::cout << "Config loaded\n";
} catch (const std::exception& e) {
std::cerr << "Config error: " << e.what() << "\n";
}
std::vector<int> data{10, 20, 30};
std::cout << getElement(data, 1) << "\n"; // 20
// getElement(data, 10); // assert fires in debug mode
return 0;
}
9.7 Exercises
Easy
-
Write a
safeDivide(double a, double b) -> std::optional<double>function that returnsnulloptwhenbis zero and the result otherwise. Test with several cases. -
Write three custom exception classes (
UserAlreadyExistsError,InvalidEmailError,WeakPasswordError) each derived fromstd::runtime_error. Write aregisterUser(name, email, password)function that throws the appropriate exception for each error case.
Medium
- Write a
readFileContents(const std::string& path) -> std::optional<std::string>function that opens a file, reads all its content, and returns the content wrapped inoptional. Returnnulloptif the file cannot be opened. Do not throw.
Stretch
- Using
std::expected<T, E>, write a CSV row parser:parseCSVRow(const std::string& line) -> std::expected<std::vector<std::string>, ParseError>. Define an appropriateParseErrorenum. Handle empty lines, unclosed quotes, and consecutive delimiters.
9.8 Common mistakes
-
Throwing
std::stringorint. Throw objects that derive fromstd::exception. Callers can catchstd::exception&as a fallback; they cannot catch arbitrary types that way. -
Catching by value.
catch (std::exception e)copies the exception and loses the derived type (slicing). Always catch byconst reference. -
Using
catch (...)to silence all errors. You are hiding bugs, not handling them. At minimum, log the error and rethrow. -
Accessing an empty
optionalwithout checking.opt.value()throwsstd::bad_optional_accessifoptis empty. Always checkif (opt)or usevalue_or. -
Using exceptions for expected/normal control flow. Throwing when a search finds no result is expensive and misleading. Use
optionalinstead. -
Throwing from destructors. If a destructor throws during stack unwinding (another exception is active),
std::terminateis called. Destructors must not throw. Catch exceptions inside destructors and handle or swallow them. -
Forgetting that
noexceptis a promise. If you mark a functionnoexceptand it throws,std::terminateis called. Only mark functionsnoexceptif they genuinely cannot throw. -
Memory leaks when an exception is thrown. If you call
newand then an exception is thrown before the correspondingdelete, you leak. Use smart pointers — RAII automatically handles this.
9.9 Summary
C++ offers multiple error-handling mechanisms, each suited to different situations.
Exceptions propagate errors up the call stack. They are for unexpected failures that callers cannot easily anticipate. Throw by value; catch by const reference; always derive from std::exception.
RAII ensures resources are released even when exceptions are thrown. Destructors run regardless of how a scope is exited.
std::optional<T> represents "a value or nothing." Use it when absence is a normal outcome, not an error.
std::expected<T, E> (C++23) represents "a value or a typed error." More expressive than optional, cheaper than exceptions for high-frequency failure paths.
Asserts are for programming contract violations — bugs, not runtime errors. They disappear in release builds.
In Chapter 10 we turn to concurrency. It is the hardest chapter, and we will say so again when we get there.
All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter09