Error Handling with expected (C++23)
Reviewed & published by Brayan K
std::expected<T, E> is a return type that carries either a success value or a typed error — and nothing else. It makes failure visible in a function's signature, lets you handle errors without exceptions, and chains fallible steps with monadic helpers like and_then and transform. By the end you'll return and consume expected values cleanly, and know when to pick it over exceptions or error codes.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Return a value OR a typed error with std::expected<T, E>
- Test and read results with has_value, value and error
- Build error results with std::unexpected
- Chain fallible steps with and_then, transform and or_else
- Compare expected against exceptions and integer error codes
- Use tl::expected before your compiler supports C++23
💡 Real-World Analogy
Think of ordering food at a counter. An exception is a fire alarm: when something goes wrong, an alarm blares and everyone up the chain has to react, even if they had nothing to do with your order. A bare error code is a little number on a screen you might forget to glance at, while your tray sits there separately — easy to grab the food and ignore the warning. std::expected is the tray itself: it comes back holding either your meal or a slip explaining what went wrong, never both, and you cannot eat without first looking at what is actually on the tray. The outcome — success or a clear reason for failure — is right there in your hands.
1. A Value or an Error
A function returning std::expected<T, E> hands back one of two things: a value of type T on success, or an error of type E on failure. You return the value directly on the happy path; on the error path you wrap your error with std::unexpected(e). Callers tell the cases apart with has_value() (or the implicit bool conversion), then read value() on success or error() on failure. Crucially, the failure is now part of the return type — the signature itself warns every caller.
#include <iostream>
#include <expected> // C++23
#include <string>
using namespace std;
// Returns an int on success, or a std::string error on failure.
// The failure possibility is RIGHT THERE in the return type.
expected<int, string> parsePositive(int n) {
if (n <= 0)
return unexpected(string("must be positive")); // error path
return n * 10; // value path
}
int main() {
auto good = parsePositive(5);
auto bad = parsePositive(-1);
// has_value() (or 'if (good)') tells the two cases apart:
if (good.has_value())
cout << "ok: " << good.value() << endl; // ok: 50
if (!bad)
cout << "err: " << bad.error() << endl; // err: must be positive
return 0;
}
// ✅ Expected output:
// ok: 50
// err: must be positiveWhen an error simply means "use a default", value_or collapses the whole check into one expression — it returns the value on success or your fallback on failure, with no branching:
#include <iostream>
#include <expected>
#include <string>
using namespace std;
expected<int, string> readSetting(bool present) {
if (!present) return unexpected(string("missing"));
return 256;
}
int main() {
// value_or supplies a fallback for the error case -- no branching:
int a = readSetting(true ).value_or(0); // 256 (value present)
int b = readSetting(false).value_or(0); // 0 (fell back)
cout << "a = " << a << endl; // a = 256
cout << "b = " << b << endl; // b = 0
return 0;
}
// ✅ Expected output:
// a = 256
// b = 0Your turn. Complete doubleIfNonNeg so it reports an error for negative input using std::unexpected:
#include <iostream>
#include <expected>
#include <string>
using namespace std;
// safeSqrtInt returns the integer square step, or an error for negatives.
expected<int, string> doubleIfNonNeg(int n) {
// 🎯 YOUR TURN — return an ERROR when n is negative.
if (n < 0)
return ___; // 👉 unexpected(string("negative"))
return n * 2; // success path
}
int main() {
cout << doubleIfNonNeg(21).value_or(-1) << endl; // 42
cout << doubleIfNonNeg(-3).value_or(-1) << endl; // -1 (error -> fallback)
// ✅ Expected output:
// 42
// -1
return 0;
}2. Chaining with and_then, transform & or_else
Real code rarely fails in one place — it threads a value through several fallible steps. Instead of nesting if-checks, std::expected offers monadic helpers. and_then runs a function that itself returns an expected, but only when a value is present; an error short-circuits straight through. transform maps the success value with a plain function (T -> U) and rewraps the result. or_else runs only on the error path, so you can recover or translate the error. The pipeline then reads top to bottom, and the first failure skips the rest.
#include <iostream>
#include <expected>
#include <string>
using namespace std;
expected<int, string> toInt(const string& s) {
if (s.empty()) return unexpected(string("empty"));
return (int)s.size(); // pretend-parse: length as the value
}
int main() {
string input = "hello";
// Chain fallible + pure steps WITHOUT manual if-checks.
// - and_then: function returns an expected (here, a validity check)
// - transform: function returns a plain value (T -> U), auto-rewrapped
// - or_else: only runs on the ERROR path, to recover
auto result = toInt(input)
.and_then([](int n) -> expected<int, string> {
if (n > 100) return unexpected(string("too long"));
return n; // pass the value on
})
.transform([](int n) { return n * n; }) // 5 -> 25
.or_else([](const string& e) -> expected<int, string> {
cout << "recovering from: " << e << endl; // (not hit here)
return 0;
});
cout << "result: " << result.value() << endl; // result: 25
// An EARLY error short-circuits the whole chain straight to or_else:
auto failed = toInt("")
.transform([](int n) { return n + 1; }) // skipped
.value_or(-1);
cout << "failed: " << failed << endl; // failed: -1
return 0;
}
// ✅ Expected output:
// result: 25
// failed: -1🧩 Reorder Challenge
These lines should call a fallible function and print either the value or the error. Put them in the right order:
A. auto r = parsePositive(5);
B. if (r) cout << r.value();
C. else cout << r.error();
D. int main() {
E. return 0;
F. }Correct order: D → A → B → C → E → F
Open main (D), call the fallible function (A), print the value when present (B), otherwise print the error (C), then return 0; (E) and close the brace (F).
3. expected vs Exceptions vs Error Codes
Three ways to report failure, three trade-offs. An exception propagates invisibly up the stack until something catches it — great for rare, non-local errors, but the failure is absent from the signature and has a cost on the throw path. A bare error code is explicit and cheap, but the actual result usually lands in a separate out-parameter you can read whether or not you checked the code. std::expected bundles the value and the typed error into one return: failure is visible in the signature, the value and the error are mutually exclusive, and you can't read the value while ignoring the failure.
📊 Comparing the three approaches
| Approach | Failure visible in signature? | Carries a typed reason? | Best for |
|---|---|---|---|
| exceptions | no | yes (the exception) | rare, non-local, hard-to-recover errors |
| error code | partly (return int) | weakly (a number) | low-level / C-style boundaries |
| expected | yes | yes (type E) | recoverable, expected failures |
Rule of thumb: reach for expected when failure is a normal, recoverable outcome; keep exceptions for the genuinely exceptional.
🔎 Deep Dive: a richer error type, and pre-C++23 code
E can be anything. A std::string message is the simplest choice, but for real systems prefer a small enum class of error codes, or a struct carrying a code and context. Because the error type is part of expected<T, E>, callers can switch on it exhaustively instead of string-matching, and you keep the type safety the whole way up.
Not on C++23 yet? tl::expected is a header-only library with the same interface, including the monadic helpers, that works back to C++11. Write your code in the expected style now, and when you move to C++23 you switch from tl::expected / tl::unexpected to std::expected / std::unexpected with little more than an include and namespace change.
enum class Err { NotFound, BadInput, Timeout }; // typed, switchable
expected<Config, Err> load(string_view path); // E is an enum, not a string
auto c = load("app.cfg");
if (!c) {
switch (c.error()) { // exhaustive handling
case Err::NotFound: /* ... */ break;
case Err::BadInput: /* ... */ break;
case Err::Timeout: /* ... */ break;
}
}Pro Tips
- 💡 Return the value directly on success, and std::unexpected(e) on failure — no std::expected ceremony needed.
- 💡 Prefer a typed error (an enum class or struct) over a raw string so callers can handle cases exhaustively.
- 💡 Chain with and_then/transform to keep fallible pipelines flat and readable.
- 💡 Use tl::expected to write this style on pre-C++23 compilers and migrate later.
🧠 Quick Recall
Predict the result before revealing the answer.
1. What does this print?
expected<int, string> e = unexpected(string("nope"));
cout << e.value_or(-1);-1 — the expected holds an error, so value_or returns the fallback.
2. Does the transform run here?
expected<int, string> e = unexpected(string("x"));
auto r = e.transform([](int n){ return n + 1; });No — transform only runs on the value path. An error short-circuits, so r still holds the error "x".
3. How do you build the error for expected<int, string>?
return ___; // report "bad"return std::unexpected(std::string("bad")); — unexpected wraps the value as the error alternative.
Common Errors (and the fix)
- Calling .value() on an error: reading value() when the expected holds an error throws std::bad_expected_access. Check has_value() first, or use value_or().
- Reading .error() on a value: error() is only valid in the error state. Test the result before reaching for the error.
- Returning a bare error object: return myError; won't form the error state for many E; wrap it with std::unexpected(myError).
- Confusing and_then and transform: and_then's callback must return an expected; transform's returns a plain value. Mixing them up causes type errors.
- Same T and E type: if T and E are the same type, construction can be ambiguous — use std::unexpected explicitly so the error path is unmistakable.
📋 Quick Reference
| Task | Code |
|---|---|
| Declare | expected<int, string> f(); |
| Return success | return 42; |
| Return error | return unexpected(e); |
| Test | if (r) / r.has_value() |
| Read value / error | r.value() / r.error() |
| Fallback | r.value_or(def) |
| Chain | r.and_then(f).transform(g) |
| Recover | r.or_else(h) |
Mini-Challenge: A Tiny Calculator Pipeline
Write two fallible steps returning expected<int, string>, chain them with and_then and transform, and watch an early error short-circuit the whole pipeline straight to value_or.
#include <iostream>
#include <expected>
#include <string>
using namespace std;
// 🎯 MINI-CHALLENGE: a tiny calculator pipeline
// 1. Write expected<int, string> half(int n) that returns n/2 on an
// even n, or unexpected(string("not even")) on an odd n.
// 2. Write expected<int, string> checkRange(int n) that returns n when
// 0 <= n <= 50, else unexpected(string("out of range")).
// 3. In main, chain them: half(x).and_then(checkRange)
// then .transform([](int n){ return n + 1; })
// and print value_or(-1).
// 4. Try x = 80 (even, in range) and x = 7 (odd -> error short-circuits).
//
// ✅ For x = 80: half=40, in range, +1 -> 41
// ✅ For x = 7: odd -> error -> value_or gives -1
// your code here🎉 Lesson Complete
- ✅ std::expected<T, E> returns a value OR a typed error — the failure is in the signature
- ✅ Return the value on success and std::unexpected(e) on failure
- ✅ Test with has_value(); read value()/error() or fall back with value_or()
- ✅ Chain fallible steps with and_then, transform and or_else — errors short-circuit
- ✅ Prefer it over exceptions for recoverable failures; use tl::expected before C++23
- ✅ Congratulations — you've completed this track of the C++ course!
Practice quiz
What does std::expected<T, E> represent?
- A value of type T OR an error of type E, but never both
- A value that is always present
- A pointer to T or to E
- A pair containing both a T and an E
Answer: A value of type T OR an error of type E, but never both. std::expected<T, E> holds either a success value of type T or an error of type E — exactly one of the two at a time.
Which header and standard introduced std::expected?
- <variant>, C++20
- <system_error>, C++11
- <expected>, C++23
- <optional>, C++17
Answer: <expected>, C++23. std::expected lives in the <expected> header and was added in C++23.
How do you check whether a std::expected holds a value rather than an error?
- exp.is_ok()
- exp.has_value() (or the implicit bool conversion)
- exp.valid()
- exp.success()
Answer: exp.has_value() (or the implicit bool conversion). has_value() returns true on success; the type also converts to bool, so you can write if (exp) to test it.
How do you construct an error result for std::expected<int, std::string>?
- throw std::string("bad input");
- return -1;
- return nullptr;
- return std::unexpected(std::string("bad input"));
Answer: return std::unexpected(std::string("bad input"));. std::unexpected(e) wraps a value as the error alternative, which then implicitly converts to the expected's error state.
What does exp.error() return?
- The stored error object (only valid when has_value() is false)
- A bool
- Always the default error
- The success value
Answer: The stored error object (only valid when has_value() is false). error() accesses the stored error of type E; it is only meaningful when the expected is in the error state.
What does and_then do on a std::expected?
- Replaces the error with a default
- Chains a function that itself returns an expected, only when a value is present
- Always runs, ignoring errors
- Throws if there is an error
Answer: Chains a function that itself returns an expected, only when a value is present. and_then calls the given function (which returns another expected) only on the value; an error short-circuits straight through.
How does transform differ from and_then?
- transform ignores the value
- transform only works on errors
- They are identical
- transform's function returns a plain value (T -> U), which is rewrapped into expected
Answer: transform's function returns a plain value (T -> U), which is rewrapped into expected. transform maps the success value with a function returning a plain U and rewraps it; and_then expects a function that already returns an expected.
Which monadic helper lets you handle or recover from the error case?
- and_then
- transform
- or_else
- value_or
Answer: or_else. or_else runs its function only when the expected holds an error, letting you recover or produce a different expected.
Compared with exceptions, a key property of std::expected is that:
- It always allocates on the heap
- Errors are part of the function's return type and visible in its signature
- It cannot carry an error message
- It can only report integer error codes
Answer: Errors are part of the function's return type and visible in its signature. expected makes the possible failure explicit in the return type, so callers must acknowledge it — unlike an exception that travels invisibly up the stack.
Before C++23, which library provided a drop-in std::expected-like type?
- tl::expected
- std::experimental::optional
- absl::Status only
- boost::any
Answer: tl::expected. tl::expected is a widely used header-only implementation that mirrors std::expected for codebases not yet on C++23.
Continue this course
- Previous: string_view & Non-Owning Views
- Quick reference: C++ cheat sheet
Frequently asked questions
What is std::expected<T, E> and why use it?
std::expected<T, E> (C++23, header <expected>) is a return type that holds either a success value of type T or an error of type E — never both. You use it to report recoverable failures without throwing exceptions and without resorting to a separate error code plus an out-parameter. The big win is honesty: the possibility of failure is written into the function's return type, so every caller has to acknowledge it.
How do I return an error from a function returning std::expected?
Return the value directly on success (it converts into the expected), and on failure return std::unexpected(e), which wraps your error object into the error alternative. For example, a function returning std::expected<int, std::string> can return 42; on success and return std::unexpected(std::string("bad input")); on failure. The caller then checks has_value() (or the bool conversion) and reads either value() or error().
What do and_then, transform and or_else do?
They are monadic helpers that let you chain operations without writing repetitive if-checks. and_then runs a function that itself returns an expected, but only when a value is present — an error short-circuits through untouched. transform maps the success value with a function returning a plain value (T -> U) and rewraps the result. or_else runs only on the error path, letting you recover or translate the error. Together they let a pipeline of fallible steps read top to bottom.
When should I use std::expected instead of exceptions?
Use std::expected for failures that are expected and recoverable — a parse that may fail, a file that may be missing, input that may be invalid — especially in performance-sensitive or exception-free code. Reserve exceptions for truly exceptional, hard-to-recover situations and for errors that must propagate across many layers without each one re-checking. expected makes the error path explicit and local; exceptions make it invisible and non-local.
What can I do before my compiler supports C++23?
Use tl::expected, a popular header-only library that provides a std::expected-compatible type with the same interface, including the monadic and_then/transform/or_else helpers. It works on C++11 and later, so you can write code in the std::expected style today and switch to the standard type by changing the include and the namespace once you move to C++23.