C++23 std::expected and std::optional Monadic Operations: A Practical Guide
Master C++23 monadic operations for std::expected and std::optional.
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
- ✓Basic knowledge of C++ templates and lambda expressions
- ✓Familiarity with std::optional and std::expected
- ✓Understanding of functional programming concepts (map, flatMap) is helpful but not required
- Monadic operations (map, and_then, or_else) allow chaining operations on std::optional and std::expected without manual error checking.
- std::optional represents a value that may or may not be present; monadic operations simplify handling missing values.
- std::expected represents a value or an error; monadic operations propagate errors automatically.
- These operations reduce boilerplate and improve code readability and safety.
- C++23 introduced these operations, making functional-style error handling idiomatic.
Think of std::optional as a box that either contains a gift or is empty. Monadic operations let you transform the gift (map), perform another box-opening step (and_then), or provide a default gift if empty (or_else). std::expected is like a box that either contains a gift or a note explaining why it's missing. Monadic operations let you transform the gift, chain steps that might fail, or handle the error note gracefully.
Error handling in C++ has traditionally been a mix of exceptions, error codes, and boolean flags. While exceptions work well for exceptional cases, they are not suitable for expected errors or performance-critical code. Error codes lead to repetitive if-else checks that obscure the main logic. C++17 introduced std::optional and std::variant, and C++23 added std::expected, but the real game-changer is the monadic operations (map, and_then, or_else) that allow functional-style composition. These operations let you chain operations on optional or expected values without manual unwrapping, making code more concise, readable, and less error-prone. In this tutorial, we'll explore how to use these operations in production scenarios, from parsing configuration files to handling network responses. You'll learn the syntax, see real-world examples, and understand common pitfalls. By the end, you'll be able to write robust, expressive error-handling code that scales with your application's complexity.
What are Monadic Operations?
Monadic operations are functional programming patterns that allow you to chain operations on values wrapped in a context (like optional or expected) without manually unwrapping. The three core operations are: - map: Transform the contained value if present, otherwise return an empty context. - and_then: Apply a function that returns a new context (e.g., optional) to the contained value; if empty, propagate the empty. - or_else: Provide an alternative context if the current one is empty.
These operations eliminate the need for explicit if-else checks, making code more declarative and less error-prone. In C++23, std::optional and std::expected gained these methods, bringing functional error handling to the mainstream.
std::optional Monadic Operations
std::optional gained three monadic methods in C++23: map, and_then, and or_else. Let's explore each with examples.
map transforms the contained value using a callable that returns a non-optional value. If the optional is empty, map returns an empty optional.
and_then is similar but the callable must return an optional (or a type convertible to optional). This is useful for chaining operations that may fail.
or_else provides a fallback optional if the current one is empty. The callable returns an optional of the same type.
These methods work with lambdas, function pointers, or any callable. They are constexpr and noexcept where possible.
std::expected Monadic Operations
std::expected<T, E> represents either a value of type T or an error of type E. C++23 added monadic operations: map, and_then, or_else, and also transform_error (which maps the error).
- map: Transform the value if present; if error, propagate the error unchanged.
- and_then: Apply a function that returns a new expected; if error, propagate.
- or_else: Handle the error by providing a new expected (value or error).
- transform_error: Transform the error (like map but on the error side).
These operations make error handling composable and reduce boilerplate.
Practical Example: Configuration Parser
Let's build a configuration parser that reads key-value pairs from a string. We'll use std::expected to handle parsing errors and std::optional for optional keys. Monadic operations will chain the parsing steps cleanly.
We define a Config struct with fields that may be optional. The parser returns std::expected<Config, std::string> on error. We'll use and_then to chain field parsing and map to transform values.
Common Pitfalls and Best Practices
While monadic operations are powerful, they come with pitfalls:
- Overusing map when and_then is needed: If your lambda returns an optional/expected, use and_then, not map. map would wrap it in a nested optional (optional<optional<T>>).
- Ignoring error types: For std::expected, always consider the error type. Using a generic string may lose information. Prefer enums or small structs.
- Performance: Monadic operations are generally zero-cost abstractions, but beware of capturing large objects in lambdas. Use std::move when appropriate.
- Exception safety: Lambdas should not throw; if they do, the behavior is undefined for std::optional (it will call std::terminate). For std::expected, exceptions propagate normally.
- Readability: Long chains can become hard to read. Break them into named lambdas or intermediate variables.
- Use and_then for fallible operations, map for infallible transformations.
- Use or_else to provide fallbacks or log errors.
- For std::expected, use transform_error to convert errors to a common type.
- Avoid mixing std::optional and std::expected in the same chain; stick to one type.
Performance Considerations
Monadic operations are designed to be zero-cost abstractions. The compiler can inline the lambdas and optimize away the optional/expected wrappers. However, there are cases where performance can degrade:
- Copying large objects: If your optional/expected wraps a large type, each monadic operation may copy it. Use std::move in lambdas or store by pointer.
- Multiple chaining: Each step adds a branch (check if has value). For very long chains, consider restructuring.
- Error handling: std::expected stores an error even on the value path; this may increase size. Use std::optional if no error info is needed.
Benchmarking is key. In most cases, the readability gains outweigh any minor performance cost.
Migrating from Legacy Code
Many codebases use error codes or exceptions. Migrating to std::expected and monadic operations can improve safety and readability. Here's a step-by-step approach:
- Identify functions that return bool + out parameter or throw on expected errors.
- Replace with std::expected<T, ErrorCode>.
- Replace nested if-else chains with monadic operations.
- Use or_else for error handling and logging.
Example: A function that parses a number and returns bool with an int reference.
The Silent Crash: Missing Configuration Key
- Always handle missing optional values explicitly; avoid .value() without checks.
- Use monadic operations to chain operations and propagate errors naturally.
- Log errors with context to aid debugging.
- Prefer std::expected over std::optional when error details are needed.
- Test with missing/invalid inputs to ensure robustness.
if (opt) { use opt.value(); }opt.value_or(default);| File | Command / Code | Purpose |
|---|---|---|
| monadic_intro.cpp | std::optional | What are Monadic Operations? |
| optional_monadic.cpp | std::optional | std |
| expected_monadic.cpp | enum class ParseError { InvalidInput, OutOfRange }; | std |
| config_parser.cpp | struct Config { | Practical Example |
| pitfalls.cpp | std::optional | Common Pitfalls and Best Practices |
| performance.cpp | struct BigData { | Performance Considerations |
| migration.cpp | bool parse_int_legacy(const std::string& s, int& out) { | Migrating from Legacy Code |
Key takeaways
Common mistakes to avoid
5 patternsUsing map when the lambda returns an optional
Calling .value() on optional/expected without checking
Throwing exceptions inside monadic lambdas for std::optional
Ignoring the error type in std::expected
Chaining too many operations without readability
Interview Questions on This Topic
Explain the monadic operations available on std::optional in C++23.
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
That's C++ Advanced. Mark it forged?
3 min read · try the examples if you haven't