Skip to Content
Course content

96: std::expected for Error Handling

Click on the "Edit" button in the top corner of the screen to edit your slide content.

For years, we've been caught in a tug-of-war between two extremes of error handling in C++. On one side, you have exceptions, which are powerful but can make the control flow feel like a magic trick—suddenly you're jumping three levels up the call stack. On the other, you have the "C-style" approach: returning a magic number like -1 or a bool to indicate failure. I've spent way too many hours debugging production crashes because someone forgot to check a return code, or because a -1 was actually a valid piece of data.

The Frustration of the Silent Null

When std::optional arrived, it felt like a breath of fresh air. It explicitly told the caller, "This function might not return a value." But std::optional has a glaring weakness: it's silent. It tells you that something failed, but it never tells you why. Consider a function that parses a configuration value from a file. If it returns std::nullopt, is the file missing? Is the key misspelled? Is the value the wrong data type?

// The "I don't know what went wrong" approach
std::optional<int> parse_port(const std::string& input) {
    try {
        return std::stoi(input);
    } catch (...) {
        return std::nullopt; // We lost all the context of the error here
    }
}

In a small tool, this is fine. In a complex system, this is a nightmare. You end up adding logging statements everywhere just to figure out which nullopt triggered the failure. You're essentially guessing at the state of the system.

Explicit Intent with std::expected

This is where std::expected (introduced in C++23) changes the game. Think of it as a specialized union: it contains either the expected value or an error object. It gives you the semantic clarity of an optional, but it carries the payload of an exception without the overhead of stack unwinding.

If we rewrite that port parser, we can define a specific error type—maybe an enum class—to tell the caller exactly what happened. Now, the function signature itself becomes a piece of documentation.

enum class ParseError {
    InvalidFormat,
    OutOfRange,
    EmptyInput
};

std::expected<int, ParseError> parse_port(const std::string& input) {
    if (input.empty()) {
        return std::unexpected(ParseError::EmptyInput);
    }
    
    try {
        return std::stoi(input);
    } catch (const std::invalid_argument&) {
        return std::unexpected(ParseError::InvalidFormat);
    } catch (const std::out_of_range&) {
        return std::unexpected(ParseError::OutOfRange);
    }
}

Notice the use of std::unexpected. It's a wrapper that tells the compiler, "I'm not returning the value; I'm returning the error." It prevents the compiler from accidentally confusing an error integer with a success integer.

Balancing Verbosity and Safety

Now, you might ask: "Why not just use exceptions?" I usually tell my teammates that exceptions are for exceptional circumstances—things that shouldn't happen in a healthy system, like running out of memory or a lost database connection. But a user typing a letter into a port number field isn't exceptional; it's expected. Using std::expected for these cases keeps your "happy path" clean while forcing the caller to acknowledge the error explicitly.

The trade-off is a bit more verbosity at the call site. You can't just call the function and forget about the result; you have to check it. But in my experience, that extra line of code is a cheap price to pay for not waking up at 3 AM to a crash dump that just says "Value was null".

auto result = parse_port("8080");
if (result) {
    std::cout << "Port is: " << *result << "\n";
} else {
    switch (result.error()) {
        case ParseError::InvalidFormat: std::cerr << "Not a number!\n"; break;
        case ParseError::OutOfRange:    std::cerr << "Number too big!\n"; break;
        case ParseError::EmptyInput:    std::cerr << "Input was empty!\n"; break;
    }
}

By returning the error in-band, you're treating error handling as a first-class part of your API design rather than a side-channel mechanism. It makes the code predictable, testable, and significantly more robust.




📋 Practical Task

Implement a Robust User Permission Validator

You are building a security module for a system. You need to write a function validate_permission that checks if a user has a specific access level. Instead of returning a simple boolean, use std::expected to provide detailed feedback on why a request was denied.

Requirements:

  • Define an enum PermissionError with the following values: UserNotFound, InsufficientLevel, and AccountLocked.
  • Create a function std::expected<bool, PermissionError> validate_permission(int userId, int requiredLevel).
  • Simulate a database lookup:
    • If userId is negative, return UserNotFound.
    • If userId is 0, return AccountLocked.
    • If the userId is positive, simulate a user level (e.g., if userId % 2 == 0, they are level 10; otherwise, level 1). If their level is less than requiredLevel, return InsufficientLevel.
    • Otherwise, return true.
  • In your main function, call this validator with three different cases (one for each error and one success) and print a user-friendly message based on the specific PermissionError returned.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.