Modern Error Handling: std::optional and std::expected
Error Handling, Tooling, and Multi-File Programs

8.5 Modern Error Handling: std::optional and std::expected

Note

std::expected requires C++23 (<expected>). GCC 12+ and recent Clang/libc++ builds support it; check your toolchain before using it in the playground.

Traditional exceptions are excellent for exceptional, unexpected events (like hardware failure), but using them for expected errors (like a file not found or invalid user input) degrades performance. Modern C++ provides lightweight, monadic alternatives that avoid exceptions entirely.

Let's look at std::optional and C++23 std::expected.

Nullable States: `std::optional` (C++17)

A std::optional<T> is a stack allocated wrapper that holds either a valid value of type T or nothing (std::nullopt). It represents a function that can fail to return a value, without using exceptions or magic null values:

#include <iostream>
#include <optional>
#include <string>

std::optional<int> parseAge(const std::string& str) {
    try {
        return std::stoi(str); // Convert string to integer
    } catch (...) {
        return std::nullopt; // Return empty state on failure!
    }
}

int main() {
    auto age = parseAge("invalid");
    if (age.has_value()) {
        std::cout << "Age: " << age.value() << '\n';
    } else {
        std::cout << "Invalid age string!\n";
    }
    return 0;
}
Returning nullable states using std::optional.

Value or Error: `std::expected` (C++23)

Often, returning an empty state is not enough; you want to return a detailed error code on failure. C++23 solved this by introducing std::expected<T, E>. It holds either the expected value of type T or an error object of type E:

#include <iostream>
#include <expected>
#include <string>

enum class FileError {
    NotFound,
    AccessDenied
};

std::expected<std::string, FileError> readFile(const std::string& filename) {
    if (filename == "secret.txt") {
        return std::unexpected(FileError::AccessDenied); // Return error object
    }
    if (filename != "log.txt") {
        return std::unexpected(FileError::NotFound); // Return error object
    }
    return "File content data..."; // Return expected value
}

int main() {
    auto result = readFile("secret.txt");
    if (result) {
        std::cout << "Content: " << *result << '\n';
    } else {
        if (result.error() == FileError::AccessDenied) {
            std::cout << "Error: Access Denied!\n";
        }
    }
    return 0;
}
Returning values or error codes using std::expected.

std::optional and std::expected store their contained object inline within the wrapper, wherever the wrapper itself is stored. They make success and failure explicit in the type system; their cost versus exceptions depends on error frequency, object size, API design, and generated code.

  • The Mailbox Metaphor: Using optional is like checking your physical mailbox. Instead of screaming and calling the post office (throwing an exception) if there is no mail, you simply open the box and find it empty (std::nullopt). It is a normal, expected result that you check calmly.

Monadic Chaining: and_then and transform

C++23 introduces monadic operations for std::optional and std::expected. Instead of writing deeply nested if statements to check value statuses, you can chain operations cleanly using .and_then() (which maps functions returning optionals/expecteds) and .transform() (which maps functions returning raw values):

#include <iostream>
#include <optional>
#include <string>

std::optional<int> getUserId() { return 42; }
std::optional<std::string> getUsername(int id) { return "Alice"; }

int main() {
    // Clean pipeline: chains operations without checks!
    auto result = getUserId()
        .and_then(getUsername)
        .transform([](const std::string& name) { return "User: " + name; });

    if (result) {
        std::cout << *result << '\n'; // Prints: User: Alice
    }
    return 0;
}
Chaining monadic operations in C++23.
Finished reading this lesson?