C++ Error-Handling Strategies: optional, expected & More
Why Multiple Strategies?
C++ offers more error-handling options than any other mainstream language. Exceptions, return codes, std::optional, std::expected, errno, std::error_code — each has tradeoffs in performance, safety, and expressiveness. No single approach fits every situation.
Exceptions work well for errors that are truly unexpected and require unwinding several call levels. Return codes are fast and predictable but easy to ignore. std::optional elegantly handles “no result” cases. std::expected combines “result or error” in a single type. Real codebases often use multiple strategies, with clear boundaries between them.
This lesson covers every major strategy, compares their tradeoffs, and gives guidelines for choosing the right one.
Return Codes
The simplest and oldest strategy: return a value that indicates success or failure. This is what C uses, and many C++ codebases still prefer it for performance-critical code:
#include <iostream>
#include <string>
using namespace std;
enum class ErrorCode {
OK = 0,
FileNotFound,
PermissionDenied,
DiskFull,
InvalidFormat
};
string errorMessage(ErrorCode e) {
switch (e) {
case ErrorCode::OK: return "Success";
case ErrorCode::FileNotFound: return "File not found";
case ErrorCode::PermissionDenied: return "Permission denied";
case ErrorCode::DiskFull: return "Disk full";
case ErrorCode::InvalidFormat: return "Invalid format";
}
return "Unknown error";
}
// Return error code, output through parameter
ErrorCode readFile(const string& path, string& content) {
if (path.empty()) return ErrorCode::InvalidFormat;
if (path == "/secret") return ErrorCode::PermissionDenied;
if (path == "/missing") return ErrorCode::FileNotFound;
content = "File contents of " + path;
return ErrorCode::OK;
}
int main() {
string content;
ErrorCode err = readFile("/data.txt", content);
if (err == ErrorCode::OK) {
cout << content << endl;
} else {
cout << "Error: " << errorMessage(err) << endl;
}
err = readFile("/missing", content);
if (err != ErrorCode::OK) {
cout << "Error: " << errorMessage(err) << endl;
}
return 0;
}
Pros: zero overhead, predictable performance, no hidden control flow, works everywhere (no compiler flags needed).
Cons: callers can ignore the return value, output parameters are awkward, error propagation is manual (every caller must check and forward the error code).
Modern C++ has [[nodiscard]] to help with the ignoring problem:
[[nodiscard]] ErrorCode riskyOperation() {
return ErrorCode::DiskFull;
}
int main() {
riskyOperation(); // WARNING: ignoring return value of nodiscard function
}
errno and C-Style Errors
C APIs use a global (thread-local) variable errno to report errors. You must check it immediately after a function call:
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cmath>
using namespace std;
int main() {
// File I/O with errno
FILE* f = fopen("/nonexistent/file.txt", "r");
if (!f) {
cout << "fopen failed: " << strerror(errno) << endl;
// fopen failed: No such file or directory
}
// Math functions set errno
errno = 0;
double result = log(-1.0); // domain error
if (errno == EDOM) {
cout << "log(-1): domain error" << endl;
}
errno = 0;
result = exp(1000); // overflow
if (errno == ERANGE) {
cout << "exp(1000): range error (overflow)" << endl;
}
return 0;
}
Avoid errno in new C++ code. It is easy to forget to check, easy to check too late (another call may overwrite it), and does not work with C++ types. Use it only when calling C library functions that require it.
std::optional (C++17)
std::optional<T> represents a value that may or may not exist. It replaces the common patterns of returning sentinel values (-1, nullptr, empty strings) to indicate “no result”:
#include <iostream>
#include <optional>
#include <string>
#include <map>
using namespace std;
class Database {
map<int, string> users = {{1, "Alice"}, {2, "Bob"}, {3, "Charlie"}};
public:
// Returns the user if found, or nothing
optional<string> findUser(int id) const {
auto it = users.find(id);
if (it != users.end()) {
return it->second; // has value
}
return nullopt; // no value
}
// Returns the parsed integer, or nothing
static optional<int> parseInt(const string& s) {
try {
return stoi(s);
} catch (...) {
return nullopt;
}
}
};
int main() {
Database db;
// Check with has_value()
auto user = db.findUser(2);
if (user.has_value()) {
cout << "Found: " << user.value() << endl;
}
// Check with implicit bool
if (auto u = db.findUser(99)) {
cout << "Found: " << *u << endl;
} else {
cout << "User 99 not found" << endl;
}
// value_or() provides a default
cout << db.findUser(1).value_or("Unknown") << endl; // Alice
cout << db.findUser(99).value_or("Unknown") << endl; // Unknown
// Parse integers safely
auto num = Database::parseInt("42");
cout << "Parsed: " << num.value_or(-1) << endl; // 42
auto bad = Database::parseInt("abc");
cout << "Parsed: " << bad.value_or(-1) << endl; // -1
return 0;
}
When to use optional: when “no result” is a normal, expected outcome — not an error. A user lookup returning nothing is expected. A file read failing is an error. optional says “there might be nothing,” not “something went wrong.”
When NOT to use optional: when the caller needs to know why there is no value. optional cannot distinguish between “user not found,” “database disconnected,” and “permission denied.” For that, use std::expected or exceptions.
std::expected (C++23)
std::expected<T, E> holds either a value of type T (success) or an error of type E (failure). It combines the benefits of return codes (caller must handle the error) with the ergonomics of optional (no output parameters):
#include <iostream>
#include <expected>
#include <string>
#include <system_error>
using namespace std;
enum class ParseError {
EmptyInput,
InvalidCharacter,
Overflow
};
string errorString(ParseError e) {
switch (e) {
case ParseError::EmptyInput: return "empty input";
case ParseError::InvalidCharacter: return "invalid character";
case ParseError::Overflow: return "number too large";
}
return "unknown";
}
expected<int, ParseError> parsePositiveInt(const string& s) {
if (s.empty()) return unexpected(ParseError::EmptyInput);
int result = 0;
for (char c : s) {
if (c < '0' || c > '9') return unexpected(ParseError::InvalidCharacter);
int digit = c - '0';
if (result > (INT_MAX - digit) / 10) return unexpected(ParseError::Overflow);
result = result * 10 + digit;
}
return result; // success
}
// Chaining with and_then / transform
expected<double, ParseError> parseThenHalve(const string& s) {
return parsePositiveInt(s)
.transform([](int val) { return val / 2.0; });
}
int main() {
auto r1 = parsePositiveInt("42");
if (r1) {
cout << "Value: " << *r1 << endl; // 42
}
auto r2 = parsePositiveInt("abc");
if (!r2) {
cout << "Error: " << errorString(r2.error()) << endl;
// Error: invalid character
}
auto r3 = parsePositiveInt("");
if (!r3) {
cout << "Error: " << errorString(r3.error()) << endl;
// Error: empty input
}
// Chaining
auto r4 = parseThenHalve("100");
cout << "Half of 100: " << r4.value_or(0.0) << endl; // 50
return 0;
}
std::expected is the modern C++ answer to Rust’s Result<T, E>. The caller must check for errors before accessing the value (accessing the value on an error calls undefined behavior or throws). The transform and and_then methods enable functional-style chaining.
Note: std::expected requires C++23. If your compiler does not support it, use the tl::expected or boost::outcome libraries, which provide the same functionality.
std::error_code and std::error_category
The <system_error> header provides a cross-platform error framework used by the standard library (filesystem, networking):
#include <iostream>
#include <system_error>
#include <fstream>
using namespace std;
void demonstrateErrorCode() {
// Create from errno values
error_code ec = make_error_code(errc::no_such_file_or_directory);
cout << "Code: " << ec.value() << endl;
cout << "Message: " << ec.message() << endl;
cout << "Category: " << ec.category().name() << endl;
// Check for specific errors
if (ec == errc::no_such_file_or_directory) {
cout << "File not found!" << endl;
}
// error_code is falsy when no error
error_code ok;
if (!ok) {
cout << "No error" << endl; // error_code() == 0 → no error
}
}
// Functions can take error_code as output parameter
bool openFile(const string& path, error_code& ec) {
ifstream file(path);
if (!file.is_open()) {
ec = make_error_code(errc::no_such_file_or_directory);
return false;
}
ec.clear();
return true;
}
int main() {
demonstrateErrorCode();
cout << endl;
error_code ec;
if (!openFile("/nonexistent.txt", ec)) {
cout << "Failed: " << ec.message() << endl;
}
return 0;
}
The std::filesystem library uses this pattern extensively — every function has an overload that takes error_code& instead of throwing:
#include <filesystem>
namespace fs = std::filesystem;
// Throwing version:
auto size = fs::file_size("/some/file"); // throws on error
// Non-throwing version:
error_code ec;
auto size2 = fs::file_size("/some/file", ec); // sets ec on error
if (ec) { /* handle error */ }
Strategy Comparison
| Strategy | Performance | Safety | Error Info | Best For |
|---|---|---|---|---|
| Exceptions | Slow when thrown | Cannot ignore | Rich (custom types) | Rare, unexpected errors |
| Return codes | Zero overhead | Easy to ignore | Limited (int/enum) | Performance-critical C APIs |
| std::optional | Minimal | Must check | None (just absent) | “No result” is normal |
| std::expected | Minimal | Must check | Rich (error type E) | Expected failures with reason |
| error_code | Zero overhead | Easy to ignore | Moderate (code+msg) | System/platform errors |
How to Choose
Use exceptions when:
- The error is truly exceptional (file corruption, out of memory, network failure)
- The error must propagate through many call levels
- The code already uses exceptions (consistency matters)
Use std::optional when:
- A missing result is normal and expected (lookup, search, parse)
- The caller does not need to know why there is no result
Use std::expected when:
- Failure is expected but the caller needs to know the reason
- You want return-value-based error handling with rich error info
- You are writing a library that should work in exception-free codebases
Use return codes / error_code when:
- Performance is critical and errors are frequent
- Interfacing with C APIs
- Exceptions are disabled (-fno-exceptions)
The worst approach: mixing strategies randomly. Pick one primary strategy for your codebase and use others only at boundaries (e.g., wrapping C APIs).
Mixing Strategies at Boundaries
Real code often wraps one strategy in another at API boundaries:
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <cerrno>
#include <cstring>
using namespace std;
// C API uses errno
extern "C" {
int c_read_sensor(int sensorId, double* value) {
if (sensorId < 0 || sensorId > 10) {
errno = EINVAL;
return -1;
}
*value = 23.5 + sensorId;
return 0;
}
}
// C++ wrapper converts to optional
optional<double> readSensor(int id) {
double value;
if (c_read_sensor(id, &value) == 0) {
return value;
}
return nullopt;
}
// Higher-level function converts to exception
double readSensorOrThrow(int id) {
auto value = readSensor(id);
if (!value) {
throw runtime_error("Failed to read sensor " + to_string(id));
}
return *value;
}
int main() {
// Use optional where failure is expected
for (int id : {1, 5, 99}) {
auto temp = readSensor(id);
cout << "Sensor " << id << ": "
<< (temp ? to_string(*temp) : "N/A") << endl;
}
cout << endl;
// Use exception where failure is unexpected
try {
cout << "Critical sensor: " << readSensorOrThrow(3) << endl;
cout << "Bad sensor: " << readSensorOrThrow(99) << endl;
} catch (const exception& e) {
cout << "ALERT: " << e.what() << endl;
}
return 0;
}
The pattern: C API → optional wrapper → exception wrapper. Each layer uses the strategy appropriate for its callers. This is how professional C++ codebases handle the transition between C and C++ error models.
Real-World Example: HTTP Client
#include <iostream>
#include <optional>
#include <string>
#include <map>>
#include <variant>
using namespace std;
// Error types
struct NetworkError {
string message;
int code;
};
struct HttpResponse {
int statusCode;
map<string, string> headers;
string body;
bool isSuccess() const { return statusCode >= 200 && statusCode < 300; }
bool isClientError() const { return statusCode >= 400 && statusCode < 500; }
bool isServerError() const { return statusCode >= 500; }
};
// Result type: either a response or an error
using HttpResult = variant<HttpResponse, NetworkError>;
// Simulated HTTP client
class HttpClient {
public:
HttpResult get(const string& url) {
// Simulate different outcomes
if (url.find("timeout") != string::npos) {
return NetworkError{"Connection timed out", -1};
}
if (url.find("404") != string::npos) {
return HttpResponse{404, {{"content-type", "text/html"}}, "Not Found"};
}
if (url.find("500") != string::npos) {
return HttpResponse{500, {}, "Internal Server Error"};
}
return HttpResponse{200, {{"content-type", "application/json"}},
R"({"status": "ok", "data": [1, 2, 3]})"};
}
};
// Helper to extract successful response or nullopt
optional<string> fetchJson(HttpClient& client, const string& url) {
auto result = client.get(url);
if (auto* err = get_if<NetworkError>(&result)) {
cout << "Network error: " << err->message << endl;
return nullopt;
}
auto& response = get<HttpResponse>(result);
if (!response.isSuccess()) {
cout << "HTTP " << response.statusCode << ": " << response.body << endl;
return nullopt;
}
return response.body;
}
int main() {
HttpClient client;
cout << "--- Successful request ---" << endl;
if (auto json = fetchJson(client, "https://api.example.com/data")) {
cout << "Got: " << *json << endl;
}
cout << "\n--- 404 request ---" << endl;
fetchJson(client, "https://api.example.com/404");
cout << "\n--- Timeout ---" << endl;
fetchJson(client, "https://api.example.com/timeout");
return 0;
}
This example uses std::variant as a result type (success or error), then std::optional for the higher-level helper that only cares about success or failure. No exceptions are used — errors are values that flow through the type system. This is the style preferred by codebases that avoid exceptions.
Practice Exercises
Exercise 1: Write a parseInt function three ways: one using exceptions, one using std::optional, and one using return codes. Compare the caller code for each. Which is most readable?
Exercise 2: Create a Result<T> template class (your own std::expected) that holds either a value or an error string. Implement value(), error(), has_value(), and value_or().
Exercise 3: Wrap the C file I/O functions (fopen, fread, fwrite, fclose) in a modern C++ class that uses RAII for cleanup and std::optional or std::expected for error handling. Test by reading a file that exists and one that does not.
Exercise 4: Build a configuration loader that reads a JSON-like config file. Use std::expected for parsing (returns parse errors), std::optional for looking up config keys (missing key is normal), and exceptions for truly unexpected errors (out of memory).
Summary
C++ provides multiple error-handling strategies, each suited for different scenarios. Return codes are fast but easy to ignore. errno is legacy — avoid in new code. std::optional cleanly represents “no result” without explaining why. std::expected (C++23) is the modern answer to “result or error” with type safety. std::error_code provides a cross-platform error framework. Exceptions handle truly unexpected failures with automatic propagation. The key principle: pick one primary strategy for your codebase and use others only at boundaries. Never mix strategies randomly — consistency matters more than picking the “best” approach.