Appearance
C++ Error Design: Exceptions, Optional Values, and Results
No one mechanism fits every unsuccessful outcome. Error design begins by separating expected absence, recoverable operational failure, programmer contract violation, and unrecoverable process state.
Exceptions separate failure propagation from local return values
cpp
Report load_report(const std::filesystem::path& path) {
std::ifstream input{path};
if (!input) {
throw std::runtime_error{"cannot open report: " + path.string()};
}
return parse_report(input);
}An exception propagates until a matching handler, destroying automatic objects along the way. Catch where code can recover, translate a boundary, or add meaningful context—not immediately around every throwing call.
RAII is essential because manual cleanup paths are skipped during unwinding. Destructors should be non-throwing. Exception objects should retain structured information where callers need more than a message.
Exception safety describes state guarantees
An operation can offer:
- no-throw: it will not emit an exception;
- strong guarantee: failure leaves observable state unchanged;
- basic guarantee: invariants remain and resources do not leak;
- no guarantee beyond stated preconditions.
Build new state in temporary values and commit with swaps or moves to achieve strong guarantees naturally. Mark a function noexcept only when its implementation and called operations support the promise; violation terminates the program.
Move operations marked noexcept allow containers to relocate elements with stronger guarantees.
optional models expected absence
cpp
std::optional<Record> find_record(Id id);No matching record is often not exceptional. optional<T> holds either a T or nothing, but carries no error detail. Do not overload absence to represent parsing failure, permission denial, and cancellation.
Result types carry success or error
C++23 standardizes std::expected<T, E>. Earlier baselines use a library implementation or a domain result type. Results make failure visible in the return type and work well across no-exception or FFI boundaries.
cpp
enum class ParseError { empty, invalid, out_of_range };
std::expected<int, ParseError> parse_count(std::string_view text);
std::expected<Report, ParseError> parse_report(std::string_view text) {
auto count = parse_count(text);
if (!count) {
return std::unexpected{count.error()};
}
return Report{*count};
}This explicit propagation works in C++23. Proposals and third-party result libraries may provide monadic helpers such as and_then; use the API supplied by the selected baseline rather than assuming Rust's ? or Swift's try syntax exists in C++.
They also require explicit propagation and can be ignored unless APIs and warnings discourage it. Exceptions and results are design choices with different composition and binary-boundary tradeoffs, not moral categories.
error_code represents system-style error domains
std::error_code stores a numeric value plus an error_category that interprets it. Standard facilities use it for portable condition comparisons and for non-throwing overloads of filesystem and other system-facing operations:
cpp
std::error_code error;
const bool exists = std::filesystem::exists(path, error);
if (error) {
return LoadError{error, path};
}std::system_error is an exception carrying an error_code. Use it when exception propagation fits the API; retain the code so callers can inspect categories and conditions instead of parsing a message. Platform codes such as errno or Windows error values require the matching category and should be translated at a boundary rather than compared as unexplained integers throughout the program.
Assertions identify violated programmer assumptions
assert is for conditions that should be impossible in a correct program and may disappear under NDEBUG. It is not input validation or production error handling. Public preconditions should be documented and, where necessary, enforced through errors or types.
Translate at boundaries
A command-line main can catch domain exceptions, print a concise diagnostic to standard error, and return a nonzero status. A C API should not let C++ exceptions escape; catch them and translate to a C-compatible error protocol. Destructors and callbacks crossing foreign code need equally explicit policies.
Optional prompts
Explain: Why is optional insufficient for a file loader that must distinguish missing, denied, and malformed files?
Answer: It represents only value versus absence. A result type or exception can retain failure categories and context.
Explain: Why does RAII matter even more in exception-using code?
Answer: Stack unwinding bypasses later manual cleanup statements but still destroys fully constructed automatic objects, making resource release reliable on every exit path.