C++23 std::expected Error-Handling Pipelines
A CSV parser whose every fallible step returns std::expected
The starting point: one fallible step
The cppreference page for std::expected teaches the type with one example, parse_number, which returns std::expected<double, parse_error> for a single string. The caller checks has_value(), reads the value, or reads the error. That example is complete for one step, and it stops where real parsers start: a second fallible step.
With two fallible steps and no chaining, each step needs its own check before the next step runs, and each check needs the error carried forward by hand. With three steps, the checks outnumber the work. Exceptions avoid the checks by leaving the function entirely, which makes the failure path invisible in the signature. Error codes avoid the checks by putting the error in a side channel, an out-parameter or a global, which the caller can ignore without the compiler noticing.
The pipeline in this guide keeps the failure in the return type and removes the checks. Each step returns std::expected<T, E>, and the steps join with and_then, transform, or_else, and transform_error. The whole pipeline for one line is one expression:
std::expected<Record, ParseError> process(std::string_view line) {
return split(line).and_then(to_record).and_then(validate);
}
The rest of this guide defines those three steps, picks the error type deliberately, adds recovery and a void step, and inspects the result exactly once, at the end. The input is a line of comma-separated text in the form name,age,score, such as ada,36,91.
Choosing the error type
The chain in this guide uses an enum. An enum gives a fixed, closed set of failure cases that the caller can switch over, and it costs nothing to copy through the chain:
enum class ParseError {
empty_line,
bad_field_count,
bad_age,
bad_score,
age_out_of_range,
score_out_of_range,
};
const char* describe(ParseError e) {
switch (e) {
case ParseError::empty_line: return "empty line";
case ParseError::bad_field_count: return "expected 3 fields";
case ParseError::bad_age: return "age is not a number";
case ParseError::bad_score: return "score is not a number";
case ParseError::age_out_of_range: return "age out of range 0-150";
case ParseError::score_out_of_range: return "score out of range 0-100";
}
return "unknown error";
}
struct Record {
std::string name;
int age;
int score;
};
The enum choice is forced by the one rule that decides whether a chain compiles. The cppreference and_then page states it plainly: if the function returns a type that is not a specialization of std::expected, or its error type differs from E, the program is ill-formed. Every step in a chain must return std::expected<T, E> with the same E. A step that returns a different error type does not get converted or adapted; the build fails.
The failure is a static assertion, not a quiet conversion. A test program chaining a step that returns std::expected<int, ErrorA> into a step that returns std::expected<std::string, ErrorB> fails on GCC 13 with this error, quoted from the actual output:
error: static assertion failed: the function passed to
std::expected<T, E>::and_then must return a std::expected with the
same error_type
That rule is the reason the error type is a design decision, not an afterthought. Pick E before writing the steps, because changing it later means changing every step.
A struct error type is the right choice when the error must carry context, such as a line number. This variant was compiled and run separately from the main parser:
struct ParseFailure {
std::string message;
int line;
};
std::expected<int, ParseFailure> parse_age(std::string_view text) {
int value = 0;
std::size_t used = 0;
try {
value = std::stoi(std::string(text), &used);
} catch (...) {
return std::unexpected(ParseFailure{"age is not a number", 0});
}
if (used != text.size()) {
return std::unexpected(ParseFailure{"age is not a number", 0});
}
if (value < 0 || value > 150) {
return std::unexpected(ParseFailure{"age out of range 0-150", 0});
}
return value;
}
Its output, from the program that uses transform_error to fill in the line number, is:
line 7: age is not a number
age: 36
A std::string error type is legal: E must be destructible and a valid argument for std::unexpected, and std::string meets both. It is still the wrong default for a chain. Every error allocates, and there is no fixed set of cases for the caller to switch over. The place for a string is at the boundary, where the error is rendered for a human, as the transform_error example in the recovery section shows. The worked decision for this parser: an enum for the chain, a struct when context must travel with the error, and a string only as the final rendered form.
The chain
The first step splits the line. It returns the fields, or the reason the line cannot be split:
std::expected<std::vector<std::string>, ParseError> split(
std::string_view line) {
if (line.empty()) {
return std::unexpected(ParseError::empty_line);
}
std::vector<std::string> fields;
std::size_t start = 0;
while (true) {
std::size_t comma = line.find(',', start);
if (comma == std::string_view::npos) {
fields.emplace_back(line.substr(start));
break;
}
fields.emplace_back(line.substr(start, comma - start));
start = comma + 1;
}
if (fields.size() != 3) {
return std::unexpected(ParseError::bad_field_count);
}
return fields;
}
The second step turns the fields into a record. It needs two fallible conversions, age and score, and and_then joins one value to one next step, not two values to one step. So the combination happens inside this step, with explicit checks on each conversion. That is a limit worth knowing before writing a chain: and_then composes a sequence, and a step that needs two independent results unwraps them itself.
std::expected<int, ParseError> parse_int(std::string_view text,
ParseError error) {
int value = 0;
const char* first = text.data();
const char* last = first + text.size();
auto [ptr, ec] = std::from_chars(first, last, value);
if (ec != std::errc() || ptr != last) {
return std::unexpected(error);
}
return value;
}
std::expected<Record, ParseError> to_record(
std::vector<std::string> fields) {
auto age = parse_int(fields[1], ParseError::bad_age);
if (!age) return std::unexpected(age.error());
auto score = parse_int(fields[2], ParseError::bad_score);
if (!score) return std::unexpected(score.error());
return Record{fields[0], *age, *score};
}
The third step checks the ranges. It takes a record and returns a record, so the shape of the value does not change, only whether it is allowed through:
std::expected<Record, ParseError> validate(Record record) {
if (record.age < 0 || record.age > 150) {
return std::unexpected(ParseError::age_out_of_range);
}
if (record.score < 0 || record.score > 100) {
return std::unexpected(ParseError::score_out_of_range);
}
return record;
}
Each operation in the chain has a fixed effect on the two sides of the type. and_then takes the value, hands it to a function that must return an expected with the same error type, and passes an error through untouched. transform takes the value, hands it to a plain function, and wraps the result back into an expected with the same error type; the error side is never touched. The formatting step uses transform, because formatting cannot fail:
std::string format_record(Record record) {
return record.name + " (" + std::to_string(record.age) +
"): " + std::to_string(record.score);
}
In the main program, each input line runs through the chain, and the formatted form is produced with transform followed by value_or, which supplies a fallback string when the chain failed:
for (std::string_view line : lines) {
auto result = process(line);
std::cout << "line: \"" << line << "\" -> ";
if (result) {
std::cout << "ok: " << result->name << '\n';
} else {
std::cout << "error: " << describe(result.error()) << '\n';
}
auto shown = process(line)
.transform(format_record)
.value_or(std::string("unparseable"));
std::cout << " shown: " << shown << '\n';
}
The output for six input lines, from the compiled program, is:
line: "ada,36,91" -> ok: ada
shown: ada (36): 91
line: "grace,85,102" -> error: score out of range 0-100
shown: unparseable
line: "lin,notanumber,50" -> error: age is not a number
shown: unparseable
line: "no-commas-here" -> error: expected 3 fields
shown: unparseable
line: "" -> error: empty line
shown: unparseable
line: "alan,200,10" -> error: age out of range 0-150
shown: unparseable
Notice what the output shows about short-circuiting. The grace line fails at validation, after parsing succeeded, and the error names the validation failure, not the parse. The lin line fails at parsing, and validation never runs. The first failure wins, and later steps never see a value that failed an earlier step.
Recovery: or_else and transform_error
Recovery is the mirror image of the chain. and_then and transform act when a value is present. or_else acts when an error is present: it receives the error and must return an expected with the same value type. transform_error acts when an error is present and rewrites the error, leaving a value untouched.
Recovery belongs where the fallback is known. In this parser, a score out of range can fall back to a placeholder record, but only the caller knows that a zero score is an acceptable substitute. So the or_else sits after the whole chain, not inside it. A recovery placed mid-chain would hide the failure from the steps after it, which is the right call only when a later step genuinely does not need to know:
auto recovered = process("grace,85,102").or_else([](ParseError e) {
if (e == ParseError::score_out_of_range) {
return std::expected<Record, ParseError>{
Record{"grace", 85, 0}};
}
return std::expected<Record, ParseError>{std::unexpected(e)};
});
std::cout << "recovered: " << recovered->name << " score "
<< recovered->score << '\n';
Its output is:
recovered: grace score 0
transform_error is for the boundary, where the error leaves the chain and becomes a message. It changes the error type, so after it, the chain carries the new error type, and any further and_then step must return that new type. Here it converts the enum into a string, and the result is read with error_or:
auto messaged = process("lin,notanumber,50")
.transform_error(
[](ParseError e) { return std::string(describe(e)); });
std::cout << "message: " << messaged.error_or("no error") << '\n';
Its output is:
message: age is not a number
The void step
Some steps produce no value, only success or failure: writing a record, deleting a file, committing a transaction. std::expected has a partial specialization for void, std::expected<void, E>, and the chain handles it with two different shapes that are easy to confuse.
The first shape is a step that takes a value and returns a void result. The store step takes a record, appends it, and returns an empty success:
std::vector<Record> stored;
std::expected<void, ParseError> store(Record record) {
stored.push_back(record);
return {};
}
Chaining it looks like any other and_then step, because the function still receives the record:
std::expected<void, ParseError> stored_result =
split("ada,36,91").and_then(to_record).and_then(validate).and_then(store);
The second shape is a step chained onto a void result. There is no value to hand to the function, so the function takes no argument at all. The cppreference and_then page lists these as overloads 5 through 8: the callable is invoked without any argument. The program demonstrates it directly on the stored result:
stored_result.and_then([] {
std::cout << "stored " << stored.size() << " record(s)\n";
return std::expected<void, ParseError>{};
});
Its output is:
stored 1 record(s)
Writing a void step that takes a value argument, or a value step that returns a bare void, are both compile errors, and the error points at the overload set. The rule to remember: the argument a step receives is the value of the expected it is chained onto, and a void expected has no value to give.
Exactly one inspection place
The chain ends at one place, and that place is the only code that looks inside the result. value_or supplies a fallback value when the chain failed. error_or supplies a fallback error when the chain succeeded. value() returns the value and throws std::bad_expected_access when the chain failed. The throwing form exists for the boundary where a failure is genuinely exceptional, and the program shows it failing on purpose:
try {
auto bad = process("alan,200,10");
std::cout << bad.value().name << '\n';
} catch (const std::bad_expected_access<ParseError>& ex) {
std::cout << "value() threw: " << ex.what() << '\n';
}
Its output, on GCC 13, is:
value() threw: bad access to std::expected without expected value
Inside the chain, no step calls value(), error(), or operator*. Those inspections belong to the boundary. A step that inspects the previous result with value() has left the chain and reintroduced the manual check the chain exists to remove.
Compiler matrix
The cppreference C++23 compiler-support table, re-opened for this build, gives the floors: the std::expected class template in GCC 12, Clang 16 with libc++, and MSVC 19.33; the monadic operations in GCC 13, Clang 17 with libc++, and MSVC 19.36. The feature-test macro tells the two apart: __cpp_lib_expected is 202202L for the class template and 202211L with the monadic operations. The build floor for a pipeline is therefore GCC 13 or later, Clang 17 or later with libc++, or MSVC 19.36 or later, at -std=c++23.
What was actually tested on the build box is narrower, and the table below says so. GCC 12 was not tested: no GCC 12 is installed on the box, so the GCC 12 row is a table fact, not an observed result. MSVC was not tested at all. Clang was tested and failed, for a reason specific to this box.
| Compiler | <expected> |
Monadic operations | Result on this box |
|---|---|---|---|
| GCC 12 | yes, per table | no, per table | Not installed; not tested |
| GCC 13.3.0 | yes | yes, macro 202211 |
Pass: all programs compile and run |
| Clang 18.1.3 with libstdc++ 13 | per table, libc++ 16 | per table, libc++ 17 | Fail: header is empty, see below |
| Clang with libc++ 16/17 | yes, per table | yes, per table | No libc++ on this box; not tested |
| MSVC 19.33 / 19.36 | yes, per table | yes, per table | Not tested |
The Clang failure is not a missing header. Clang 18 finds the libstdc++ 13 <expected> header and reads it as empty, because the header is guarded on __cpp_concepts >= 202002L and this Clang reports __cpp_concepts as 201907L. The first error is:
error: no template named 'expected' in namespace 'std'
The consequence is that no program using std::expected compiles with Clang 18.1.3 against this box's libstdc++ 13 headers, monadic or not, and the feature-test macro is not defined under that pairing. The cppreference table cells for Clang describe libc++, which this box does not have. A reader on Clang needs libc++ 16 or later for the class template and 17 or later for the monadic operations, or a newer Clang, and should verify on their own machine rather than trusting the GCC result here.
Guard the monadic code with the feature-test macro, so a compiler with the class template but without the monadic operations fails with a readable message instead of a missing-member error:
#if defined(__cpp_lib_expected) && __cpp_lib_expected >= 202211L
std::cout << "monadic operations available\n";
#elif defined(__cpp_lib_expected)
std::cout << "expected without monadics\n";
#else
std::cout << "no expected\n";
#endif
On this box the guard prints monadic operations available under GCC 13.3.0 and no expected under Clang 18.1.3, matching the compile results above.
Frequently asked questions
Does every step have to return std::expected?
No. Steps that cannot fail return plain values through transform, as the formatting step does. Only steps that can fail return std::expected. A step that returns std::expected where it belongs in an and_then chain, and a plain value where it belongs in a transform, keeps the chain honest about which steps can fail.
Can the error type change in the middle of a chain?
Only through transform_error, which rewrites the error and starts a new segment of the chain carrying the new error type. After a transform_error, every later and_then step must return the new error type. There is no operation that changes the error type silently.
Why not std::optional plus an error code?
std::optional carries no error, so the reason for a failure needs a second channel, and the compiler cannot check that the caller read it. std::expected carries the value and the error in one return type, and the monadic operations force the error to be handled by passing it through. The cost is the same-error-type rule; the benefit is that a forgotten error is a compile error, not a silent default.
What comes next in this course?
The next guide in course order is C++23 std::mdspan and Deducing This: A Matrix and Image View Library. It builds a matrix and image view library over one flat buffer, and it needed GCC 16 on the build box, which the guide installed by extracting Ubuntu's g++-16 packages into a user directory.
Source
- cppreference, std::expected — the class template, the
voidspecialization, the member-function tables, the feature-test macro values, and theparse_numberexample. - cppreference, std::expected
::and_then — the same-error-type constraint, the void-specialization overloads that take no argument, and the202211Lmacro value. - cppreference, C++23 — the compiler-support table:
<expected>in GCC 12, Clang libc++ 16, MSVC 19.33; monadic operations in GCC 13, Clang libc++ 17, MSVC 19.36.
Facts verified 2026-11-04. Every code listing on this page was compiled and run with GCC 13.3.0 at -std=c++23 on Ubuntu 24.04, including the programs that fail: the error messages are quoted from the actual compiler output, and the failing programs are labeled as failing. The same programs do not compile with Clang 18.1.3 against this box's libstdc++ 13 headers, for the reason given in the compiler matrix; no Clang result is claimed. MSVC was not tested.
Keep Exploring
Keep exploring the docs, samples, and free tools to continue your evaluation.
Score SPF, DKIM, DMARC, MTA-STS, TLS-RPT, and BIMI for any domain.
Review grounded audit, compare, fix-plan, and report excerpts before you wire the API into anything.
Jump from the workflow page into the quickstart, endpoint guides, and full OpenAPI reference.