Error Handling with std::expected
C++23 introduces std::expected<T, E>, a vocabulary type for functions that can return either a value or an error. It provides a type-safe alternative to exceptions and error codes.
Basic Concept
std::expected<T, E> holds either a value of type T (success) or an error of type E (failure):
#include <expected>
#include <string>
std::expected<int, std::string> parse_int(const std::string& str) {
try {
return std::stoi(str);
} catch (...) {
return std::unexpected("Invalid integer format");
}
}
auto result = parse_int("42");
if (result) {
std::cout << "Parsed: " << *result << '\n'; // 42
} else {
std::cout << "Error: " << result.error() << '\n';
}
Checking Success vs Failure
std::expected<double, std::string> divide(double a, double b) {
if (b == 0.0) {
return std::unexpected("Division by zero");
}
return a / b;
}
auto result = divide(10.0, 2.0);
// Method 1: Contextual conversion to bool
if (result) {
double value = *result; // Dereference to get value
}
// Method 2: has_value()
if (result.has_value()) {
double value = result.value(); // Throws if error
}
// Method 3: Check for error
if (!result) {
std::string err = result.error();
}
Accessing Values
auto result = divide(10.0, 2.0);
// Dereference (undefined behavior if error)
double v1 = *result;
double v2 = result.operator*();
// value() - throws std::bad_expected_access if error
double v3 = result.value();
// value_or() - returns default if error
double v4 = result.value_or(0.0);
Error Types
Using Enums
enum class Error {
FileNotFound,
PermissionDenied,
InvalidFormat
};
std::expected<std::string, Error> read_file(const std::string& path) {
if (!file_exists(path)) {
return std::unexpected(Error::FileNotFound);
}
if (!has_permission(path)) {
return std::unexpected(Error::PermissionDenied);
}
// Read and return content
return file_content;
}
Custom Error Types
struct ParseError {
std::string message;
size_t position;
ParseError(std::string msg, size_t pos)
: message(std::move(msg)), position(pos) {}
};
std::expected<int, ParseError> parse_number(const std::string& str) {
for (size_t i = 0; i < str.length(); ++i) {
if (!std::isdigit(str[i])) {
return std::unexpected(ParseError{
"Invalid character", i
});
}
}
return std::stoi(str);
}
Monadic Operations
C++23 expected supports monadic operations for chaining:
and_then: Chain Operations
std::expected<int, std::string> parse_int(const std::string& s);
std::expected<int, std::string> validate_positive(int n);
std::expected<double, std::string> compute_sqrt(int n);
auto result = parse_int("16")
.and_then(validate_positive)
.and_then(compute_sqrt);
if (result) {
std::cout << "Square root: " << *result << '\n'; // 4.0
}
Implementation:
std::expected<int, std::string> validate_positive(int n) {
if (n > 0) {
return n;
}
return std::unexpected("Number must be positive");
}
std::expected<double, std::string> compute_sqrt(int n) {
return std::sqrt(n);
}
or_else: Handle Errors
auto result = parse_int("invalid")
.or_else([](const std::string& err) -> std::expected<int, std::string> {
std::cerr << "Parse error: " << err << '\n';
return 0; // Return default value
});
transform: Map Values
auto result = parse_int("42")
.transform([](int n) { return n * 2; }); // std::expected<int, std::string>
if (result) {
std::cout << *result << '\n'; // 84
}
transform_error: Map Errors
auto result = parse_int("invalid")
.transform_error([](const std::string& err) {
return Error::InvalidFormat; // Convert error type
});
// result is std::expected<int, Error>
Practical Example: File Processing Pipeline
enum class FileError {
NotFound,
PermissionDenied,
InvalidFormat,
ProcessingFailed
};
std::expected<std::string, FileError> read_file(const std::string& path);
std::expected<Data, FileError> parse_data(const std::string& content);
std::expected<Result, FileError> process_data(const Data& data);
std::expected<void, FileError> save_result(const Result& result);
// Chain operations
auto outcome = read_file("input.txt")
.and_then(parse_data)
.and_then(process_data)
.and_then([](const Result& r) {
return save_result(r);
});
if (!outcome) {
switch (outcome.error()) {
case FileError::NotFound:
std::cerr << "File not found\n";
break;
case FileError::PermissionDenied:
std::cerr << "Permission denied\n";
break;
// ...
}
}
std::expected<void, E>
For operations that don’t return a value:
std::expected<void, std::string> validate_config(const Config& cfg) {
if (cfg.port < 1024) {
return std::unexpected("Port must be >= 1024");
}
if (cfg.host.empty()) {
return std::unexpected("Host cannot be empty");
}
return {}; // Success
}
auto result = validate_config(config);
if (!result) {
std::cerr << "Validation failed: " << result.error() << '\n';
}
Comparison with Alternatives
vs Exceptions
// Exceptions: implicit control flow
double divide_throws(double a, double b) {
if (b == 0.0) throw std::runtime_error("Division by zero");
return a / b;
}
try {
auto result = divide_throws(10.0, 0.0);
} catch (const std::exception& e) {
// Handle error
}
// std::expected: explicit error handling
std::expected<double, std::string> divide_expected(double a, double b) {
if (b == 0.0) return std::unexpected("Division by zero");
return a / b;
}
auto result = divide_expected(10.0, 0.0);
if (!result) {
// Handle error explicitly
}
vs std::optional
// std::optional: success/failure, but no error information
std::optional<int> parse_int_opt(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
return std::nullopt; // No error information
}
}
// std::expected: success/failure with error details
std::expected<int, std::string> parse_int_exp(const std::string& s) {
try {
return std::stoi(s);
} catch (const std::exception& e) {
return std::unexpected(e.what()); // Error details preserved
}
}
vs Error Codes
// C-style error codes
int parse_int_code(const std::string& s, int* out, std::string* error) {
try {
*out = std::stoi(s);
return 0; // Success
} catch (const std::exception& e) {
*error = e.what();
return -1; // Failure
}
}
// std::expected: type-safe, ergonomic
std::expected<int, std::string> parse_int(const std::string& s) {
try {
return std::stoi(s);
} catch (const std::exception& e) {
return std::unexpected(e.what());
}
}
Error Propagation
Simplify error propagation with monadic operations:
std::expected<int, Error> step1();
std::expected<double, Error> step2(int);
std::expected<std::string, Error> step3(double);
std::expected<std::string, Error> process() {
return step1()
.and_then(step2)
.and_then(step3);
// Error automatically propagates through chain
}
Best Practices
- Use for recoverable errors: std::expected is ideal for expected failures (validation, parsing, I/O)
- Use exceptions for exceptional cases: Programming errors, violations of invariants
- Consistent error types: Use enums or custom error types across a module
- Leverage monadic operations: Chain operations for clean error handling
- Document error cases: Clearly document what errors a function can return
When to Use std::expected
✅ Use when:
- Errors are expected and recoverable
- Performance is critical (no exception overhead)
- Error information needs to be propagated
- Explicit error handling is preferred
❌ Don’t use when:
- Errors are truly exceptional (use exceptions)
- Error handling is optional (use std::optional)
- Working with legacy code that uses error codes
Conceptual Check
What does std::expected<T, E> contain?
Interactive Lab
Complete the Code
std::expected<double, std::string> safe_sqrt(double x) { if (x < 0) { return std::("Cannot compute sqrt of negative number"); } return std::sqrt(x); }
Runtime Environment
Interactive Lab
1#include <iostream>
2#include <expected>
3#include <string>
4#include <cmath>
5
6std::expected<double, std::string> safe_divide(double a, double b) {
7 if (b == 0.0) {
8 return std::unexpected("Division by zero");
9 }
10 return a / b;
11}
12
13std::expected<double, std::string> safe_sqrt(double x) {
14 if (x < 0) {
15 return std::unexpected("Cannot compute sqrt of negative");
16 }
17 return std::sqrt(x);
18}
19
20int main() {
21 auto result = safe_divide(16.0, 4.0)
22 .and_then(safe_sqrt);
23
24 if (result) {
25 std::cout << "Result: " << *result << '\n';
26 } else {
27 std::cout << "Error: " << result.error() << '\n';
28 }
29
30 auto error_result = safe_divide(10.0, 0.0)
31 .and_then(safe_sqrt);
32
33 if (!error_result) {
34 std::cout << "Error: " << error_result.error() << '\n';
35 }
36
37 return 0;
38}
System Console
Waiting for signal...