std::expected in C++23: An Architecture Note for Fallible Operations
An architecture note on std::expected (C++23): requirements for adopting it, the minimal design, ABI boundaries where it must not appear, static-analysis checks, common failure modes, and when to choose a different strategy.
28 Nov 2025, 02:22 UTC

The problem this solves
Most C++ codebases signal failure in at least three ways at once: error-code out-parameters, sentinel return values, and exceptions thrown from deep in a call chain. Each caller has to guess which convention a function uses, and the compiler cannot help you check a convention that lives only in comments. std::expected<T, E>, standardized in C++23, collapses this into one return type that carries either a value of type T or an error of type E — never both, never neither. The useful takeaway: adopt it as the single fallible-return convention inside a module, keep it away from ABI boundaries, and enforce checking with static analysis rather than code review.
This note assumes C++23. Compiler support is recent: GCC 12+, Clang 16+, and MSVC 19.36+ (Visual Studio 2022 17.6+) ship <expected>, but standard-library completeness varies, so verify the header and monadic operations on your exact toolchain before committing.
Requirements
- One return type per fallible function that makes the success/failure contract visible in the signature.
- Exhaustive handling at call sites: accessing the value without checking must be a detectable bug, not a silent default.
- No hidden control-flow edges: exceptions should not be part of ordinary error handling in the affected module.
- Cheap error propagation: the error type must be small and non-allocating so returning it costs no more than an error code.
The smallest suitable design
Return std::expected<T, E> where E is a lightweight, trivially copyable error type — an enum class or a small struct of enums and integers. Callers inspect has_value() or chain with the monadic operations and_then, transform, and or_else:
#include <expected>
#include <string_view>
enum class ParseError { empty_input, invalid_digit, overflow };
std::expected<int, ParseError> parse_port(std::string_view text);
std::expected<int, ParseError> validated_port(std::string_view text) {
return parse_port(text).and_then([](int port)
-> std::expected<int, ParseError> {
if (port < 1 || port > 65535)
return std::unexpected(ParseError::overflow);
return port;
});
}Compile this with your project's normal flags (-std=c++23 on GCC/Clang, /std:c++latest on MSVC) as a smoke test that the header and monadic operations exist on your toolchain. Note that and_then and friends are constexpr only when the callable is constexpr; a non-constexpr lambda quietly prevents compile-time evaluation. Also, E must be movable — non-movable error types fail to compile.
Trust and data boundaries
std::expected has no guaranteed layout across standard-library implementations or versions. Do not expose it on DLL/shared-library exports, C ABI layers, or plugin interfaces — two sides compiled with different toolchains may disagree on its binary representation. At those boundaries, convert to a stable C-compatible result struct (a status enum plus a union or out-slot for the value) or your existing error-code convention. Inside a single C++ module built with one toolchain, std::expected is the canonical fallible return type.
Operational checks
- Static analysis: run clang-tidy with
bugprone-unchecked-optional-access(which also modelsstd::expected) andmodernize-use-std-expectedin CI. This catches value access without a prior check far more reliably than review. - Runtime logging: log the error payload at the point where it is converted into a fallback or user-facing message. Never discard
Esilently — an ignored error is exactly the failure mode this type exists to prevent. - Unit test: force an empty
expectedand assert that.value()throwsstd::bad_expected_access. Run the suite under UBSan to catchoperator*on an empty expected, which is undefined behavior rather than a throw.
Failure modes
- Unchecked access: calling
.value()oroperator*on an empty expected..value()throws;operator*is undefined behavior. Mitigate with the clang-tidy check above. - Heavy error types: using
std::stringor vector-carryingEtypes causes unexpected allocations and copies on the error path. Prefer small enums; attach rich context at the logging point instead. - Mixed strategies: interleaving exceptions and
expectedin one call chain recreates the ambiguity you were removing. Pick one strategy per module and convert at the module edge.
Conditions that would change the design
- Pre-C++23 toolchain with no upgrade path: use a hand-rolled
Result<T, E>or the widely usedtl::expectedbackport, which tracks the standard interface closely. - Rich error context required (stack traces, dynamic messages): consider
std::expected<T, ErrorInfo>whereErrorInfoholds astd::unique_ptrto the heavy payload, or a dedicated outcome library. - Project-wide exception mandate: if all failures must be exceptions,
std::expectedadds no value — do not run both systems.
Before rolling this out, verify three things on your target compilers: the minimal example above compiles and chains correctly, clang-tidy flags an intentionally unchecked access in a test file, and your standard library's monadic operations match the C++23 spec (implementation completeness still differs between libstdc++, libc++, and the MSVC STL).
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.