Error Handling with expected (C++23)
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.
Learn Error Handling with expected (C++23) in our free C++ course — an interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free C++ course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn
💡 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.
When 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:
Your turn. Complete doubleIfNonNeg so it reports an error for negative input using std::unexpected :
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.
🧩 Reorder Challenge
These lines should call a fallible function and print either the value or the error. Put them in the right order:
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.
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 — the expected holds an error, so value_or returns the fallback.
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 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)
Frequently Asked Questions
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 .
🎉 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 and Non-Owning Views