Which Error Handling Strategy?
Decision guide for choosing between exceptions, std::expected, std::optional, error codes, and outcome/result types in C++.
Quick Decision
cpp
Is failure expected / part of normal control flow?
├── NO (truly exceptional) → exceptions
└── YES (e.g., parsing, lookup, network)
├── Need error details (code, message)?
│ └── std::expected<T, E> (C++23) or result type
└── Just "found or not"?
└── std::optional<T>
Is this performance-critical / embedded / no-exception build?
└── YES → error codes or std::expected (no-throw)
Is this a C API or calling C code?
└── error codes or errno
Is the error always fatal?
└── assert() / std::terminate() / [[noreturn]]Exceptions — Truly Exceptional Events
cpp
// Best when: failure is rare, propagation across many layers is needed
double divide(double a, double b) {
if (b == 0.0)
throw std::domain_error("division by zero");
return a / b;
}
try {
auto result = divide(10.0, 0.0);
} catch (const std::domain_error& e) {
std::println("error: {}", e.what());
} catch (const std::exception& e) {
std::println("unexpected: {}", e.what());
}
// Pros: clean call sites, propagate automatically, RAII integrates
// Cons: not usable in -fno-exceptions, adds binary size, non-deterministic latencystd::optional — Present or Not
cpp
#include <optional>
// Best when: value may not exist, no error details needed
std::optional<int> find_user_id(const std::string& name) {
auto it = db.find(name);
if (it == db.end()) return std::nullopt;
return it->second.id;
}
// Call site
auto id = find_user_id("Alice");
if (id) {
use(*id);
} else {
std::println("user not found");
}
// Monadic API (C++23)
auto result = find_user_id("Alice")
.transform([](int id) { return load_profile(id); })
.value_or(default_profile);
// Pros: explicit, zero overhead for trivial types, no exceptions
// Cons: no error details — caller doesn't know WHY it failedstd::expected — Value or Error (C++23)
cpp
#include <expected>
enum class ParseError { InvalidFormat, Overflow, Empty };
std::expected<int, ParseError> parse_int(std::string_view sv) {
if (sv.empty()) return std::unexpected(ParseError::Empty);
int result;
auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), result);
if (ec == std::errc::invalid_argument) return std::unexpected(ParseError::InvalidFormat);
if (ec == std::errc::result_out_of_range) return std::unexpected(ParseError::Overflow);
return result;
}
// Call site
auto val = parse_int("42");
if (val) {
std::println("parsed: {}", *val);
} else {
switch (val.error()) {
case ParseError::Empty: std::println("empty input"); break;
case ParseError::InvalidFormat: std::println("bad format"); break;
case ParseError::Overflow: std::println("overflow"); break;
}
}
// Monadic chaining (C++23)
auto result = parse_int(input)
.and_then([](int n) -> std::expected<double, ParseError> {
if (n < 0) return std::unexpected(ParseError::InvalidFormat);
return std::sqrt((double)n);
})
.value_or(-1.0);
// Pros: typed errors, no heap alloc, no exceptions, explicit error path
// Cons: C++23 only (use tl::expected for C++17)Error Codes — C Interop and Performance
cpp
// std::error_code — system error integration
#include <system_error>
std::error_code open_file(const std::string& path) {
FILE* f = fopen(path.c_str(), "r");
if (!f)
return std::error_code{errno, std::system_category()};
// ...
return {}; // success
}
auto ec = open_file("missing.txt");
if (ec)
std::println("error: {}", ec.message());
// Custom error category
enum class DbError { NotFound = 1, Timeout, Constraint };
struct DbErrorCategory : std::error_category {
const char* name() const noexcept override { return "database"; }
std::string message(int ev) const override {
switch (static_cast<DbError>(ev)) {
case DbError::NotFound: return "record not found";
case DbError::Timeout: return "query timed out";
case DbError::Constraint: return "constraint violation";
}
return "unknown";
}
};
const DbErrorCategory db_category_instance{};
std::error_code make_error_code(DbError e) {
return {static_cast<int>(e), db_category_instance};
}tl::expected for C++17
cpp
// Before C++23: use tl::expected (header-only, MIT)
#include <tl/expected.hpp>
tl::expected<int, std::string> parse(std::string_view s) {
// ...
return tl::unexpected("invalid input");
}Comparison Table
| Exceptions | std::optional | std::expected | Error codes | |
|---|---|---|---|---|
| Error details | what() string | None | Typed enum/string | Category + value |
| Call site noise | Low | Medium | Medium | High |
| Performance | Non-deterministic | Zero | Zero | Zero |
-fno-exceptions | No | Yes | Yes | Yes |
| Propagation | Automatic | Manual | Manual (.and_then) | Manual |
| C++17 ready | Yes | Yes | No (use tl::expected) | Yes |
| Best for | Truly exceptional | "found or not" | Expected failures | C interop, HFT |
Practical Rules
cpp
// Constructor failure → exception (can't return a value)
Matrix::Matrix(size_t rows, size_t cols) {
if (rows == 0 || cols == 0)
throw std::invalid_argument("zero dimension");
}
// Lookup → optional
std::optional<User> find_user(int id);
// Parse / convert → expected
std::expected<Config, ParseError> parse_config(std::string_view json);
// OS/IO → error_code
std::error_code write_file(const Path& p, std::span<const std::byte> data);
// Truly fatal → assert/terminate (programming error, not runtime)
void process(std::span<int> v) {
assert(!v.empty()); // caller's responsibility
}