The C++ Textbook

Part 6 · Correctness

Error handling without exceptions

Return-based error handling, and when it fits better.

By the end of this chapter you can

  • Design an API around expected or an error code
  • Explain the trade-offs against exceptions
  • Handle errors without silently discarding them

The previous chapter ended with a rule: exceptions are for the exceptional. This one is about the other case — failure that is routine, that the immediate caller expects, and that should be visible in the signature.

The problem with an invisible failure

An exception does not appear in a function’s type. You cannot tell from int parse(const std::string&) whether it throws, what it throws, or whether you should be prepared:

Two signatures, two amounts of information
#include <expected>
#include <iostream>
#include <string>

// What can go wrong here? The signature does not say.
int parse_throwing(const std::string& text) {
    return std::stoi(text);
}

enum class ParseError { empty, not_a_number };

// Every caller can see that this can fail, and how.
std::expected<int, ParseError> parse_returning(const std::string& text) {
    if (text.empty()) return std::unexpected{ParseError::empty};
    try { return std::stoi(text); }
    catch (...) { return std::unexpected{ParseError::not_a_number}; }
}

int main() {
    // Ignoring the failure is easy to do by accident:
    std::cout << "throwing version, valid input: " << parse_throwing("42") << '\n';

    // Here it is awkward to ignore, which is the point.
    if (auto result = parse_returning("abc")) {
        std::cout << *result << '\n';
    } else {
        std::cout << "returning version: rejected \"abc\"\n";
    }
}

That visibility is the central trade. An exception is invisible at the call site and impossible to forget about once thrown; a returned error is visible in the signature and easy to forget about at the call site unless the language helps.

Making it hard to ignore

[[nodiscard]] is the language’s help. A discarded return value becomes a warning:

[[nodiscard]] on a result type
#include <expected>
#include <iostream>
#include <string>

enum class WriteError { disk_full, permission_denied };

// Marking the *type* nodiscard covers every function that returns it.
struct [[nodiscard]] Status {
    bool ok = true;
    WriteError error{};
    explicit operator bool() const { return ok; }
};

Status write_config(const std::string& contents) {
    if (contents.size() > 100) return Status{false, WriteError::disk_full};
    return Status{};
}

int main() {
    write_config("small");            // warning: ignoring return value

    if (auto status = write_config(std::string(200, 'x')); !status) {
        std::cout << "write failed, and we noticed\n";
    }
}

Read the compiler warning on that first call. Marking the type [[nodiscard]] is stronger than marking each function, because it covers every function that returns it, including ones written later.

Error codes, and why they leak

The oldest pattern is an integer return with the real result through a pointer:

The C shape, and its problem
#include <iostream>
#include <string>

// 0 on success, non-zero on failure. The value comes back through a parameter.
int parse_c_style(const std::string& text, int* out) {
    if (text.empty()) return 1;
    try { *out = std::stoi(text); }
    catch (...) { return 2; }
    return 0;
}

int main() {
    int value = 0;

    if (parse_c_style("42", &value) == 0) {
        std::cout << "parsed " << value << '\n';
    }

    // The failure mode: nothing forces the check, and `value` keeps its old
    // contents when the call fails.
    parse_c_style("abc", &value);
    std::cout << "after a failed call, value is still " << value
              << "  <- stale, and nothing warned us\n";
}

Three problems. Nothing forces the check. The result travels through an out parameter, so the variable must exist before the call and may hold a stale value after it. And the return type carries no information about what the codes mean.

std::expected fixes all three: the value and the error occupy the same slot, so there is no stale variable, and reading the value without checking is an error rather than a silent read.

expected, in practice

A small pipeline
#include <expected>
#include <iostream>
#include <string>

enum class Error { empty, not_a_number, out_of_range, odd };

std::string describe(Error e) {
    switch (e) {
        case Error::empty:        return "empty input";
        case Error::not_a_number: return "not a number";
        case Error::out_of_range: return "out of range";
        case Error::odd:          return "not an even number";
    }
    return "unknown";
}

std::expected<int, Error> to_int(const std::string& text) {
    if (text.empty()) return std::unexpected{Error::empty};
    try {
        const long value = std::stol(text);
        if (value < -1000 || value > 1000) return std::unexpected{Error::out_of_range};
        return static_cast<int>(value);
    } catch (...) {
        return std::unexpected{Error::not_a_number};
    }
}

std::expected<int, Error> require_even(int value) {
    if (value % 2 != 0) return std::unexpected{Error::odd};
    return value;
}

int main() {
    for (const std::string& input : {"8", "7", "abc", "", "99999"}) {
        auto result = to_int(input)
                          .and_then(require_even)
                          .transform([](int n) { return n / 2; });

        std::cout << "\"" << input << "\" -> ";
        if (result) std::cout << "half is " << *result << '\n';
        else        std::cout << describe(result.error()) << '\n';
    }
}

and_then runs the next step only on success and passes any error through untouched; transform maps a successful value and leaves errors alone. The failure path is written once, at the end, instead of after every call — which is the ergonomic complaint about error returns, answered.

Choosing between the two

Use a return Use an exception
Failure is routine and expected Failure is genuinely exceptional
The immediate caller can handle it Only a distant caller can handle it
The failure path is as common as the success path Failure is rare enough that its cost does not matter
You want it visible in the signature Threading a result through many layers would obscure the code
Constructors are not involved A constructor must fail (it has no return value)

Two hard constraints worth naming:

A constructor cannot return an error. If construction can fail, you either throw, or you make the constructor private and provide a static factory returning std::expected.

A factory for a fallible construction
#include <expected>
#include <iostream>
#include <string>

class Port {
public:
    static std::expected<Port, std::string> create(int number) {
        if (number < 1 || number > 65535) return std::unexpected{"port out of range"};
        return Port{number};
    }
    int number() const { return number_; }

private:
    explicit Port(int number) : number_(number) {}
    int number_;
};

int main() {
    if (auto port = Port::create(8080)) {
        std::cout << "created port " << port->number() << '\n';
    }
    if (auto bad = Port::create(70000); !bad) {
        std::cout << "rejected: " << bad.error() << '\n';
    }
}

Some environments forbid exceptions entirely. Embedded targets, some game engines, and codebases built with -fno-exceptions have no choice — every failure must be a return value. It is worth knowing which world your code lives in before designing its interfaces.

What not to do

Three ways to lose an error
#include <iostream>
#include <optional>
#include <string>

std::optional<int> parse(const std::string& text) {
    try { return std::stoi(text); } catch (...) { return std::nullopt; }
}

int main() {
    // 1. Discarding the result entirely.
    parse("abc");

    // 2. value_or hiding a failure as a legitimate value.
    const int port = parse("abc").value_or(0);
    std::cout << "port " << port << "  <- is that a failure, or port zero?\n";

    // 3. Catching and continuing as if nothing happened.
    try {
        (void)std::stoi("abc");
    } catch (...) {
        // silence
    }
    std::cout << "the parse failed and nothing recorded it\n";
}

The second is the subtle one. value_or is excellent when the default is genuinely correct — value_or(80) for a port that defaults to 80 is fine. It is wrong when it converts “we do not know” into a value indistinguishable from a real answer, which is the sentinel problem from Chapter 4.8 reintroduced by hand.

Check yourself

Practice