C++23 std::expected: Returning Errors as Values, Monadic Chaining, and Access Pitfalls
Key takeaways
std::expected<T, E> (C++23, <expected>) holds either a T or an error E. It makes failure part of the function signature, forces the caller to look at it, and supports and_then/transform/or_else chaining. The main traps are unchecked operator* (UB), value() throwing bad_expected_access, and T being implicitly constructible from your error value.
What std::expected is
std::expected<T, E> is a C++23 vocabulary type from <expected> that holds either a value of type T or an error of type E, never both and never neither. It is the standard library’s version of what Rust calls Result and what many codebases had already hand-rolled or pulled in from tl::expected.
#include <expected>
#include <string>
std::expected<int, std::string> divide(int a, int b) {
if (b == 0) {
return std::unexpected{"Cannot divide by zero"};
}
return a / b;
}
Two things make this work. Returning a plain int implicitly constructs the “has value” state. Returning std::unexpected{...} constructs the “has error” state; the wrapper exists precisely so the compiler can tell which of the two you meant. Here std::unexpected{"..."} deduces std::unexpected<const char*>, which then converts into expected<int, std::string> because std::string is constructible from const char*.
The object is stored inline: an expected is roughly the size of the larger of T and E plus a flag, with no heap allocation of its own. That matters when you compare it against exceptions, because the cost model is completely different.
Why return errors as values
With exceptions, the signature int divide(int, int) says nothing about failure. A caller only learns that divide can throw by reading the implementation or the documentation, and the compiler will happily let them ignore it. With expected, the failure mode is in the type. You cannot get an int out without going through an API that makes you think about the error case.
The trade-offs are real, though, and it helps to state them without marketing:
| Aspect | Exceptions | std::expected |
|---|---|---|
| Visible in signature | No (only noexcept says “never throws”) | Yes, E is part of the return type |
| Cost on success path | Near zero with table-based unwinding | A flag check at every call site that inspects it |
| Cost on failure path | Expensive: unwinding, allocation of the exception object | Same as a normal return |
| Propagation | Automatic through any number of frames | Manual: each layer must return or map the error |
| Works in constructors / operators | Yes | Awkward: constructors cannot return expected |
| Can be ignored silently | No (it terminates if uncaught) | Yes, unless you mark functions [[nodiscard]] |
So “expected is faster than exceptions” is only true when failures are frequent. For a parser that rejects a lot of user input, returning an error is cheap and predictable. For an error that happens once per process lifetime, exceptions keep the hot path free of checks. The better rule of thumb is about semantics: use expected when failure is an ordinary, anticipated outcome that the immediate caller should handle (parse errors, “file not found”, validation), and exceptions when failure means the operation cannot meaningfully continue and should unwind several layers.
Checking and accessing the result
auto result = divide(10, 2);
if (result.has_value()) { // or simply: if (result)
std::cout << *result << '\n'; // unchecked access
std::cout << result.value() << '\n'; // checked access
} else {
std::cout << result.error() << '\n';
}
int v = result.value_or(0); // fallback if it holds an error
The four accessors behave differently, and mixing them up is where most bugs come from:
operator*andoperator->do not check. Calling them on an error state is undefined behavior, exactly like dereferencing an emptystd::optional.value()checks and throwsstd::bad_expected_access<E>if there is no value. The exception carries a copy of the error, available throughe.error().error()does not check. Calling it when a value is present is undefined behavior.value_or(x)never throws and never invokes UB, but it discards the error, so use it only when you genuinely do not care why it failed.
If value() is called on an error and nothing catches it, libstdc++ terminates with a message of this shape:
terminate called after throwing an instance of 'std::bad_expected_access<std::__cxx11::basic_string<char> >'
Seeing that in a crash log almost always means someone treated expected like a plain value and never checked it.
Chaining with and_then, transform, or_else
C++23 also gave expected monadic member functions (they arrived with a later paper, so the feature macro is __cpp_lib_expected >= 202211L). They let you write a pipeline where the first error short-circuits the rest:
and_then(f): if there is a value, callf(value);fmust itself return anexpectedwith the same error type. If there is an error, pass it through untouched.transform(f): if there is a value, callf(value)and wrap the plain result in a newexpected. Errors pass through.or_else(f): if there is an error, callf(error);fmust return anexpectedwith the same value type. Useful for recovery.transform_error(f): map the error to a different error type, for example when crossing a module boundary.
#include <charconv>
#include <expected>
#include <string>
#include <string_view>
std::expected<int, std::string> parseInt(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec == std::errc::invalid_argument) return std::unexpected{"not a number"};
if (ec == std::errc::result_out_of_range) return std::unexpected{"out of int range"};
if (ptr != s.data() + s.size()) return std::unexpected{"trailing characters"};
return value;
}
std::expected<int, std::string> validateRange(int v) {
if (v < 0 || v > 100) return std::unexpected{"expected 0-100"};
return v;
}
auto result = parseInt("50")
.and_then(validateRange) // returns expected<int, string>
.transform([](int v) { return v * 2; }); // plain int, wrapped for us
// result holds 100
Notice parseInt uses std::from_chars rather than std::stoi inside a try. std::stoi("12abc") quietly returns 12, so the tempting try { return std::stoi(s); } catch (...) { ... } version accepts garbage and also turns an exception back into control flow, which is the thing expected was supposed to avoid. from_chars reports how far it parsed, so trailing characters can be rejected explicitly.
A common compile error in these chains comes from mismatched types. If the lambda passed to and_then returns std::expected<int, const char*> instead of std::expected<int, std::string>, the library’s static assertion fires, because and_then requires the error types to match. The fix is to spell the return type (-> std::expected<int, std::string>) or use transform_error to convert first. Similarly, returning a plain int from an and_then lambda does not compile; that is what transform is for.
expected<void, E> for operations with no result
std::expected<void, std::string> saveConfig(const Config& cfg) {
if (!writeFile(cfg)) {
return std::unexpected{"write failed"};
}
return {}; // success, nothing to carry
}
expected<void, E> has no value_or and its operator* returns nothing, but has_value(), error(), and value() (which throws on error) still work. It is the natural replacement for functions that used to return bool and log the reason separately.
A realistic example: reading a file
#include <expected>
#include <fstream>
#include <sstream>
#include <string>
enum class FileError { OpenFailed, ReadFailed };
std::expected<std::string, FileError> readFile(const std::string& path) {
std::ifstream file{path, std::ios::binary};
if (!file) {
return std::unexpected{FileError::OpenFailed};
}
std::ostringstream ss;
ss << file.rdbuf();
if (file.bad()) {
return std::unexpected{FileError::ReadFailed};
}
return ss.str();
}
The enum is deliberately honest: std::ifstream does not tell you why opening failed, so an error such as PermissionDenied would be a promise the code cannot keep. If you need that distinction, use std::filesystem checks or the OS API, and carry a std::error_code as E. When you do switch over an error enum, handle every enumerator; GCC and Clang warn with -Wswitch (“enumeration value … not handled in switch”) when one is missing, and that warning is worth treating as an error for error enums.
Pitfalls I have run into
The first surprise I hit with expected is when T and E are the same or convertible types. With std::expected<std::string, std::string>, writing return "file missing"; in the failure branch compiles fine and produces a successful result whose value is the error message. Nothing warns you. The fix is a discipline rather than a flag: always wrap errors in std::unexpected, and prefer a distinct error type (an enum or a small struct) so the compiler can catch the mistake for you.
The second is plain toolchain friction. On an older compiler, #include <expected> fails with fatal error: expected: No such file or directory. On GCC 12 or newer the header exists, but without -std=c++23 (or c++2b) its contents are disabled, so you instead get errors like 'expected' in namespace 'std' does not name a template type. And code that uses and_then can compile on one machine and fail on another if one of them has the base type but not the monadic additions. When a project must support older toolchains, I have found it simpler to use tl::expected, which has almost the same interface, and switch to std::expected later behind a single alias.
A few more that are easy to miss:
- Silently ignored results.
expecteditself is not required to be[[nodiscard]], sosaveConfig(cfg);on its own line compiles without complaint in general. Mark your functions[[nodiscard]]. - Expensive error types. A large
E(say, a struct holding astd::stringand a vector of context) makes every successful return as big as the error. KeepEsmall, or store heavy context behind a pointer. - Constructors cannot return
expected. Use a static factory function (static std::expected<Widget, Error> create(...)) with a private constructor. - Converting at the edges. When
expected-based code calls a library that throws, catch at that call and translate; when exception-based code calls you,.value()at the boundary turns an error back into an exception.