Skip to main content
C++ · lesson 18 of 24
C++ intermediate Lesson 18 of 24

Error Handling in C++

Handle C++ errors with exceptions, noexcept, RAII, std::error_code and C++23 std::expected, each shown as a runnable program with real GCC 14 output.

By SkillByExample · Updated

C++ offers several error handling strategies, and choosing the right one matters for both correctness and performance. Exceptions work well for rare, unexpected failures — they propagate automatically through call stacks without touching every intermediate function. Error codes work better when failures are common and expected — parsing user input, looking up a key that might not exist. std::expected (C++23) provides a monadic middle ground that makes the error path explicit in the type system without the overhead of exceptions. Understanding all three and when to reach for each is what separates robust C++ from fragile C++.

Exceptions: try, catch, throw

Exceptions let you separate error detection (deep in a call stack) from error handling (wherever the caller that knows what to do about it lives). Without exceptions, every intermediate function in between would need to check and forward an error code. With exceptions, the error propagates automatically until caught.

#include <stdexcept>
#include <string>
#include <fstream>
#include <iostream>

std::string read_file(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open())
        throw std::runtime_error("cannot open file: " + path);

    return std::string(
        std::istreambuf_iterator<char>(file),
        std::istreambuf_iterator<char>()
    );
}

int main() {
    try {
        auto contents = read_file("config.json");
        // process contents
    } catch (const std::runtime_error& e) {
        std::cerr << "Error: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Unexpected: " << e.what() << "\n";
    } catch (...) {
        std::cerr << "Unknown exception\n";
    }
}
$ g++ -std=c++17 -Wall -o read read.cpp && ./read
Error: cannot open file: config.json

There is no config.json in the working directory, so the throw inside read_file fires, and its return statement never runs. Control jumps straight to the first handler in main whose type matches. runtime_error matches exactly, so the other two handlers are skipped.

Catch by const reference to avoid slicing. List the most-derived type first. Handlers are tried top to bottom, so putting std::exception above std::runtime_error would capture every runtime_error before the specific handler sees it. Practice task 2 shows the warning GCC gives for that.

The Standard Exception Hierarchy

The standard library provides a well-organized hierarchy. Using the right base class communicates whether the error is a programming mistake or a runtime condition, which helps callers decide how to handle it.

  • std::exception
    • std::logic_error: bugs that could have been prevented, such as a violated precondition
      • std::invalid_argument, std::out_of_range, std::length_error, std::domain_error
    • std::runtime_error: conditions only detectable at run time (I/O, OS, overflow)
      • std::overflow_error, std::underflow_error, std::range_error, std::system_error

The standard library itself throws from both branches. Catching each branch shows which one a given failure belongs to:

#include <bitset>
#include <filesystem>
#include <functional>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>

void classify(const std::string& call, const std::function<void()>& f) {
    try {
        f();
    } catch (const std::logic_error& e) {
        std::cout << call << " -> logic_error: " << e.what() << "\n";
    } catch (const std::runtime_error& e) {
        std::cout << call << " -> runtime_error: " << e.what() << "\n";
    }
}

int main() {
    std::vector<int> v{1, 2, 3};
    classify("v.at(10)", [&] { (void)v.at(10); });
    classify("std::stoi(\"abc\")", [] { std::stoi("abc"); });
    classify("bitset::to_ulong()", [] { std::bitset<100> b; b.set(80); b.to_ulong(); });
    classify("file_size(\"missing\")", [] { (void)std::filesystem::file_size("missing"); });
}
$ g++ -std=c++17 -Wall -o hierarchy hierarchy.cpp && ./hierarchy
v.at(10) -> logic_error: vector::_M_range_check: __n (which is 10) >= this->size() (which is 3)
std::stoi("abc") -> logic_error: stoi
bitset::to_ulong() -> runtime_error: _Base_bitset::_M_do_to_ulong
file_size("missing") -> runtime_error: filesystem error: cannot get file size: No such file or directory [missing]

An index past the end (out_of_range) and an unparsable string (invalid_argument) are logic errors, because the caller could have checked first. A value too large for unsigned long (overflow_error) and a missing file (filesystem_error, a system_error) are runtime errors. The what() strings are not portable. They come from libstdc++ here, and "stoi" alone shows they are not meant for end users. Throw logic_error subtypes for programming errors and runtime_error subtypes for conditions outside the program’s control. Write your own messages.

Custom Exception Classes

Deriving from the standard hierarchy lets callers catch your exceptions generically with catch (const std::exception&) while still allowing fine-grained handling when they want it. Carry enough context in the exception for the handler to make a good decision.

#include <iostream>
#include <stdexcept>
#include <string>

class DatabaseError : public std::runtime_error {
    int error_code_;
public:
    DatabaseError(int code, const std::string& msg)
        : std::runtime_error(msg), error_code_(code) {}
    int error_code() const noexcept { return error_code_; }
};

class ConnectionError : public DatabaseError {
public:
    explicit ConnectionError(const std::string& host)
        : DatabaseError(1001, "cannot connect to " + host) {}
};

void run_query(const std::string&) { throw ConnectionError("db.local"); }

int main() {
    try {
        run_query("SELECT * FROM users");
    } catch (const ConnectionError& e) {      // specific: retry the connection
        std::cout << "reconnecting: " << e.what() << "\n";
    } catch (const DatabaseError& e) {        // general: the whole family
        std::cout << "db error " << e.error_code() << ": " << e.what() << "\n";
    }
    try { run_query("SELECT 1"); }
    catch (const std::exception& e) { std::cout << "generic handler: " << e.what() << "\n"; }
}
$ g++ -std=c++17 -Wall -o database database.cpp && ./database
reconnecting: cannot connect to db.local
generic handler: cannot connect to db.local

The same ConnectionError is caught twice, at two levels of detail. The first try names the exact type and gets to react specifically. The second try only knows std::exception and still receives the message, because what() comes from the runtime_error base. That is the payoff of deriving from the standard hierarchy instead of throwing a bare int or a type of your own.

Exception Safety Guarantees

Every function that can throw should document which guarantee it provides. This is part of the function’s contract and determines how callers can safely use it.

  • nothrow: The function never throws. Guaranteed by noexcept.
  • strong: If the function throws, program state is unchanged (like a database transaction). Implement via copy-then-swap.
  • basic: If the function throws, the program is in a valid (but possibly modified) state. No resources are leaked.
  • none: Throwing may leave resources leaked or state corrupted. Never acceptable in production code.
#include <iostream>
#include <map>
#include <sstream>
#include <stdexcept>
#include <string>

class Config {
    std::map<std::string, std::string> data_;
public:
    void load(const std::string& text) {
        auto tmp = data_;                          // copy current state
        std::istringstream in(text);
        for (std::string line; std::getline(in, line);) {
            auto eq = line.find('=');
            if (eq == std::string::npos)
                throw std::invalid_argument("bad line: " + line);
            tmp[line.substr(0, eq)] = line.substr(eq + 1);  // modifies tmp, not data_
        }
        data_ = std::move(tmp);                    // commit: only reached if parsing succeeded
    }
    std::size_t size() const { return data_.size(); }
};

int main() {
    Config c;
    c.load("host=db.local\nport=5432");
    try {
        c.load("user=admin\noops");
    } catch (const std::exception& e) {
        std::cout << "load failed: " << e.what() << "\n";
    }
    std::cout << "entries: " << c.size() << "\n";
}
$ g++ -std=c++17 -Wall -o safety safety.cpp && ./safety
load failed: bad line: oops
entries: 2

The second load had already stored user=admin before it hit oops. That write went into tmp, though, and the throw skipped the commit, so data_ still holds the two entries from the first call. This is the strong guarantee. Writing into data_ directly instead would leave entries: 3 after the failed load: the object would still be valid, but would hold half of a failed load. That is only the basic guarantee. The price of the strong one is the copy of the whole map on every call.

noexcept — Telling the Compiler a Function Won’t Throw

noexcept is a promise to the compiler and to callers. Callers can rely on it, and the library checks it at compile time with the noexcept(expr) operator. The most visible effect is in containers. std::vector only moves your elements during reallocation if the move constructor is noexcept, or if the type can’t be copied at all (then it moves anyway and gives up the strong guarantee). Otherwise it copies them, so that a throw halfway through cannot leave both buffers half-filled.

#include <iostream>
#include <utility>
#include <vector>

struct Plain {
    Plain() = default;
    Plain(const Plain&) { std::cout << "copy "; }
    Plain(Plain&&) { std::cout << "move "; }
};

struct Marked {
    Marked() = default;
    Marked(const Marked&) { std::cout << "copy "; }
    Marked(Marked&&) noexcept { std::cout << "move "; }
};

template <typename T>
void grow(const char* name) {
    std::vector<T> v(3);               // size 3, capacity 3
    std::cout << name << " (noexcept move: " << noexcept(T(std::declval<T>())) << "): ";
    v.emplace_back();                  // full, so the 3 old elements are relocated
    std::cout << "\n";
}

int main() {
    std::cout << std::boolalpha;
    grow<Plain>("Plain ");
    grow<Marked>("Marked");
}
$ g++ -std=c++17 -Wall -o move_noexcept move_noexcept.cpp && ./move_noexcept
Plain  (noexcept move: false): copy copy copy 
Marked (noexcept move: true): move move move 

The two types are identical apart from one keyword, yet Plain was copied three times. Its move constructor exists and is cheaper, but the vector cannot prove it won’t throw, so it chooses the strong guarantee over speed. For a type holding a large buffer, that is a deep copy on every reallocation. Mark move constructors, destructors and swap functions noexcept. Destructors already are by default. A template can make the promise conditional, with noexcept(noexcept(std::swap(a, b))).

If an exception does try to leave a noexcept function, the runtime calls std::terminate() instead of propagating it. A function that broke that promise has a bug, not a recoverable condition. Practice task 4 shows the run.

RAII Ensures Cleanup on Exceptions

Without RAII, every throw path requires manually releasing resources. With RAII, destructors run automatically while the exception unwinds the stack to its handler. Cleanup no longer depends on remembering every exit path.

#include <cstdio>
#include <iostream>
#include <stdexcept>
#include <string>

struct File {                                   // what std::ofstream does for you
    std::FILE* f;
    explicit File(const char* path) : f(std::fopen(path, "w")) { std::cout << "  opened\n"; }
    ~File() { std::fclose(f); std::cout << "  closed\n"; }
};

void process(const std::string& data) {
    if (data.empty()) throw std::invalid_argument("empty data");
}

void bad_write(const std::string& data) {
    std::FILE* f = std::fopen("out.txt", "w"); std::cout << "  opened\n";
    process(data);                              // throws: the fclose below never runs
    std::fclose(f); std::cout << "  closed\n";
}

void good_write(const std::string& data) {
    File f("out.txt");
    process(data);                              // throws: ~File still runs
}

int main() {
    for (auto write : {bad_write, good_write}) {
        try { write(""); }
        catch (const std::exception& e) { std::cout << "  caught: " << e.what() << "\n"; }
    }
}
$ g++ -std=c++17 -Wall -o raii raii.cpp && ./raii
  opened
  caught: empty data
  opened
  closed
  caught: empty data

Both functions hit the same throw. bad_write never printed closed, so its FILE* leaked: the handle stays open until the process exits. good_write printed closed before caught. The destructor ran during unwinding, before control reached the handler in main. std::ofstream, std::unique_ptr and std::lock_guard all work the same way, so you rarely need to write a wrapper like File yourself.

Uncaught Exceptions and std::terminate

Unwinding only happens on the way to a handler. When no handler exists at all, the program ends through std::terminate(), and RAII no longer protects you.

#include <iostream>
#include <stdexcept>

struct Connection {
    Connection()  { std::cout << "connection opened\n"; }
    ~Connection() { std::cout << "connection closed\n"; }
};

void handle_request() {
    Connection conn;
    throw std::runtime_error("request timed out");
}

int main() {
    handle_request();   // nothing catches this
    std::cout << "never printed\n";
}
$ g++ -std=c++17 -Wall -o uncaught uncaught.cpp
$ ./uncaught
connection opened
terminate called after throwing an instance of 'std::runtime_error'
  what():  request timed out
Aborted (core dumped)
$ echo "exit code: $?"
exit code: 134
$ ./uncaught | cat
terminate called after throwing an instance of 'std::runtime_error'
  what():  request timed out

connection closed never appears. The standard leaves it to the implementation whether the stack is unwound when no handler matches ([except.handle]/9). GCC does not unwind: it finds no handler, calls std::terminate, and that calls abort(). The terminate called... lines are printed by libstdc++‘s default terminate handler on stderr. Aborted (core dumped) and exit code 134 (128 + SIGABRT’s 6) come from the shell. A non-interactive bash -c adds a bash: line 1: <pid> prefix to that line, as in practice task 4.

The first run is in an interactive terminal. The second pipes stdout, and connection opened disappears with it. When stdout is not a terminal it is fully buffered, and abort() kills the process without flushing that buffer. If a crash log is missing lines you expected, buffering is the likely cause. Write diagnostics to std::cerr, or put a try/catch (const std::exception&) around the body of main. Then destructors run and output is flushed.

Error Codes with std::error_code

For system-level code or libraries that can’t use exceptions, std::error_code provides a type-safe, extensible error code system. It is the standard way to report filesystem, network, and OS errors in a portable, domain-aware way.

Most std::filesystem functions come in two overloads: one throws filesystem_error, and one takes a std::error_code& and never throws.

#include <filesystem>
#include <iostream>
#include <system_error>

void make(const char* path) {
    std::error_code ec;
    bool created = std::filesystem::create_directory(path, ec);   // never throws
    if (ec)
        std::cout << path << ": " << ec.message() << " (" << ec.category().name()
                  << " " << ec.value() << ")\n";
    else
        std::cout << path << ": " << (created ? "created" : "already exists") << "\n";
}

int main() {
    make("/tmp/sbe-demo");
    make("/tmp/sbe-demo");
    make("/no/such/parent/dir");
}
$ g++ -std=c++17 -Wall -o error_code error_code.cpp && ./error_code
/tmp/sbe-demo: created
/tmp/sbe-demo: already exists
/no/such/parent/dir: No such file or directory (generic 2)

The second call is not an error. The directory already exists, so create_directory returns false and leaves ec clear. Check the return value and ec separately. The failure carries a number (2, POSIX ENOENT) and a category that says how to read it. libstdc++ reported it in the generic category, which holds the portable errno values. It did not use system.

You can plug your own error enum into the same framework. Write a category, a make_error_code overload, and an is_error_code_enum specialisation:

#include <iostream>
#include <string>
#include <system_error>

enum class AppError { ok = 0, invalid_input = 1, timeout = 2, not_found = 3 };

struct AppErrorCategory : std::error_category {
    const char* name() const noexcept override { return "app"; }
    std::string message(int ev) const override {
        switch (static_cast<AppError>(ev)) {
            case AppError::invalid_input: return "invalid input";
            case AppError::timeout:       return "operation timed out";
            case AppError::not_found:     return "resource not found";
            default:                      return "unknown error";
        }
    }
};

const std::error_category& app_category() { static AppErrorCategory cat; return cat; }
std::error_code make_error_code(AppError e) { return {static_cast<int>(e), app_category()}; }
template <> struct std::is_error_code_enum<AppError> : std::true_type {};

int main() {
    std::error_code ec = AppError::timeout;          // converts via make_error_code
    std::cout << ec.category().name() << " " << ec.value() << ": " << ec.message() << "\n";
    std::cout << std::boolalpha << (ec == AppError::timeout) << " "
              << (ec == std::errc::timed_out) << "\n";
}
$ g++ -std=c++17 -Wall -o app_error app_error.cpp && ./app_error
app 2: operation timed out
true false

The is_error_code_enum specialisation lets AppError::timeout convert to std::error_code implicitly. Without it, that first line of main does not compile. The false is the surprise. Our timeout and std::errc::timed_out mean the same thing, but they don’t compare equal, because the category never says so. Override default_error_condition (or equivalent) in AppErrorCategory to map AppError::timeout onto std::errc::timed_out. Then callers can test portable conditions without knowing your enum.

std::expected (C++23) — Monadic Error Handling

std::expected<T, E> holds either a value of type T or an error of type E. It makes the error path explicit in the type system, and a failure is an ordinary return value rather than a throw. The monadic interface (and_then, transform, or_else) lets you chain operations that might fail, short-circuiting on the first error without nested if-checks.

#include <charconv>
#include <expected>
#include <iostream>
#include <string>
#include <string_view>

std::expected<int, std::string> parse_int(std::string_view s) {
    int result = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), result);
    if (ec != std::errc{})
        return std::unexpected("not a valid integer: " + std::string(s));
    return result;
}

int main() {
    for (std::string_view input : {"42", "-7", "abc"}) {
        // Chain operations: if any step returns an error, the rest are skipped
        auto result = parse_int(input)
            .and_then([](int n) -> std::expected<int, std::string> {
                if (n < 0) return std::unexpected("must be non-negative");
                return n * 2;
            })
            .transform([](int n) { return "doubled: " + std::to_string(n); });

        std::cout << input << " -> " << (result ? *result : result.error()) << "\n";
    }
}
$ g++ -std=c++23 -Wall -o parse parse.cpp && ./parse
42 -> doubled: 84
-7 -> must be non-negative
abc -> not a valid integer: abc

Each input stops at a different stage. "42" passes all three. "-7" parses, then fails in and_then, so the transform lambda never runs. "abc" fails in parse_int, and neither lambda runs. No step checks for an earlier error itself: and_then and transform pass an error through untouched. <expected> needs -std=c++23. With -std=c++20, GCC 14 does not define std::expected. Practice task 5 fixes a gap in parse_int: it accepts "12abc".

Choosing a Strategy

SituationRecommended approach
Truly unexpected failure (I/O error, corrupt data)Exception
Expected failure on hot path (parsing, lookup)std::expected or error code
OS/system interfacestd::error_code
Precondition violation (programming bug)assert (compiled out when NDEBUG is defined) or a std::logic_error subtype
Destructor, move constructor, swapnoexcept — never throw

Exceptions shine when failures are rare and propagation would require threading error codes through many layers. Error codes shine when failures are frequent, expected, and the call site is right next to the decision point.

Practice

  1. Exercise both paths of read_file. Make main print how many bytes it read, and return exit code 1 when the file can’t be opened. Run it once without a config.json and once with one.
Solution
#include <stdexcept>
#include <string>
#include <fstream>
#include <iostream>

std::string read_file(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open())
        throw std::runtime_error("cannot open file: " + path);

    return std::string(
        std::istreambuf_iterator<char>(file),
        std::istreambuf_iterator<char>()
    );
}

int main() {
    try {
        auto contents = read_file("config.json");
        std::cout << "read " << contents.size() << " bytes: " << contents;
    } catch (const std::runtime_error& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
}
$ g++ -std=c++17 -Wall -o read read.cpp && ./read
Error: cannot open file: config.json
$ echo "{\"port\": 8080}" > config.json
$ ./read
read 15 bytes: {"port": 8080}

The throw inside read_file skips its return completely, and control lands in main’s handler. The 15 bytes are the 14 characters of the JSON plus the newline that echo adds. The error message goes to stderr, so a script can redirect it separately from real output.

  1. Put the handlers in the wrong order. Move catch (const std::exception&) above catch (const std::runtime_error&) and throw a std::runtime_error. What does the compiler say, and which handler runs?
Solution
#include <iostream>
#include <stdexcept>

void load() {
    throw std::runtime_error("disk unplugged");
}

int main() {
    try {
        load();
    } catch (const std::runtime_error& e) {
        std::cerr << "Error: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "Unexpected: " << e.what() << "\n";
    }
}
$ g++ -std=c++17 -Wall -o order order.cpp && ./order
order.cpp: In function 'int main()':
order.cpp:13:7: warning: exception of type 'std::runtime_error' will be caught by earlier handler [-Wexceptions]
   13 |     } catch (const std::runtime_error& e) {
      |       ^~~~~
order.cpp:11:7: note: for type 'std::exception'
   11 |     } catch (const std::exception& e) {
      |       ^~~~~
Unexpected: disk unplugged
$ g++ -std=c++17 -Wall -o order order_fixed.cpp && ./order
Error: disk unplugged

Handlers are tried top to bottom, and the first one whose type matches wins. A runtime_error is an exception, so the base-class handler captured it and the specific one became dead code. GCC warns about this even without -Wall. The code above is order_fixed.cpp, which puts the most-derived type first. order.cpp is the same file with the two catch blocks swapped.

  1. Carry the failing query. Give DatabaseError a query_ member, a query() accessor and a (code, query, msg) constructor. Then make run_query throw ConnectionError for an empty query, DatabaseError (code 1064) for a query containing FORM, and std::invalid_argument otherwise. Catch each one in its own handler and print the details it carries.
Solution
#include <iostream>
#include <stdexcept>
#include <string>

class DatabaseError : public std::runtime_error {
    int error_code_;
    std::string query_;
public:
    DatabaseError(int code, std::string query, std::string msg)
        : std::runtime_error(std::move(msg)), error_code_(code), query_(std::move(query)) {}
    int error_code() const noexcept { return error_code_; }
    const std::string& query() const noexcept { return query_; }
};

class ConnectionError : public DatabaseError {
public:
    ConnectionError(std::string host)
        : DatabaseError(1001, "", "cannot connect to " + host) {}
};

void run_query(const std::string& sql) {
    if (sql.empty()) throw ConnectionError("db.local");
    if (sql.find("FORM") != std::string::npos)
        throw DatabaseError(1064, sql, "syntax error near FORM");
    throw std::invalid_argument("unsupported statement");
}

int main() {
    for (std::string sql : {"", "SELECT * FORM users", "DROP TABLE users"}) {
        try {
            run_query(sql);
        } catch (const ConnectionError& e) {
            std::cout << "reconnect: " << e.what() << " (code " << e.error_code() << ")\n";
        } catch (const DatabaseError& e) {
            std::cout << "db error " << e.error_code() << ": " << e.what()
                      << " in [" << e.query() << "]\n";
        } catch (const std::exception& e) {
            std::cout << "other: " << e.what() << "\n";
        }
    }
}
$ g++ -std=c++17 -Wall -o db db.cpp && ./db
reconnect: cannot connect to db.local (code 1001)
db error 1064: syntax error near FORM in [SELECT * FORM users]
other: unsupported statement

Each exception landed in the most specific handler that matched it. ConnectionError still carries the 1001 code from its DatabaseError base. std::invalid_argument is a logic_error rather than a DatabaseError, so it fell through to the generic std::exception handler.

  1. Break a noexcept promise. Write checked_divide(int a, int b) noexcept so it throws std::invalid_argument when b is zero, and call it inside a try block that catches std::exception. Does the catch run?
Solution
#include <iostream>
#include <stdexcept>

int checked_divide(int a, int b) noexcept {
    if (b == 0) throw std::invalid_argument("division by zero");
    return a / b;
}

int main() {
    try {
        std::cout << checked_divide(10, 2) << "\n";
        std::cout << checked_divide(1, 0) << "\n";
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << "\n";
    }
}
$ g++ -std=c++17 -Wall -o noexcept noexcept.cpp && ./noexcept
noexcept.cpp: In function 'int checked_divide(int, int)':
noexcept.cpp:5:17: warning: 'throw' will always call 'terminate' [-Wterminate]
    5 |     if (b == 0) throw std::invalid_argument("division by zero");
      |                 ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
5
terminate called after throwing an instance of 'std::invalid_argument'
  what():  division by zero
bash: line 1:    12 Aborted                 (core dumped) ./noexcept
$ g++ -std=c++17 -Wall -o noexcept noexcept_fixed.cpp && ./noexcept
5
caught: division by zero

The catch never runs. When an exception tries to leave a noexcept function, the runtime calls std::terminate on the spot, without unwinding to the caller’s handler. GCC can see the throw and warns at compile time. Removing noexcept (in noexcept_fixed.cpp) lets the exception propagate normally.

  1. Reject trailing junk. With the lesson’s parse_int, "12abc" parses as 12, because from_chars stops at the first non-digit and reports success. Make it return an error unless every character was consumed, then run "42", "-7", "abc" and "12abc" through the and_then chain.
Solution
#include <charconv>
#include <expected>
#include <iostream>
#include <string>
#include <string_view>

std::expected<int, std::string> parse_int(std::string_view s) {
    int result = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), result);
    if (ec != std::errc{} || ptr != s.data() + s.size())
        return std::unexpected("not a valid integer: " + std::string(s));
    return result;
}

std::expected<int, std::string> doubled(std::string_view s) {
    return parse_int(s).and_then([](int n) -> std::expected<int, std::string> {
        if (n < 0) return std::unexpected("must be non-negative");
        return n * 2;
    });
}

int main() {
    for (std::string_view input : {"42", "-7", "abc", "12abc"}) {
        auto r = doubled(input);
        if (r) std::cout << input << " -> " << *r << "\n";
        else   std::cout << input << " -> error: " << r.error() << "\n";
    }
}
$ g++ -std=c++23 -Wall -o expected expected_lesson.cpp && ./expected
42 -> 84
-7 -> error: must be non-negative
abc -> error: not a valid integer: abc
12abc -> 24
$ g++ -std=c++23 -Wall -o expected expected.cpp && ./expected
42 -> 84
-7 -> error: must be non-negative
abc -> error: not a valid integer: abc
12abc -> error: not a valid integer: 12abc

expected_lesson.cpp is the lesson’s original parse_int with this doubled and main; expected.cpp is the version above. from_chars returns ptr, which points just past the last character it used. Comparing that pointer with the end of the input catches partial parses. -7 parses successfully and then fails in the and_then step, which shows the chain short-circuiting at the second stage rather than the first.

Frequently Asked Questions

Should I use exceptions or error codes in C++?
Use exceptions for rare failures that should travel up several layers to a handler, such as I/O errors or corrupt data. Use std::error_code or std::expected for failures the caller expects and handles right away, such as parsing input. With GCC's table-based implementation an exception costs nothing at run time until it is thrown, but throwing one is much more expensive than returning a value, so it doesn't suit failures that happen routinely.
Why should a C++ move constructor be marked noexcept?
std::vector only moves elements into its new buffer during reallocation if the move constructor is noexcept (or if the type cannot be copied at all, in which case the strong guarantee is given up). Otherwise it copies them, so a throw halfway through cannot lose data. A type whose move constructor is not noexcept therefore gets copied on every reallocation, which can be a deep copy of a large buffer.
What happens when a C++ exception is never caught?
The runtime calls std::terminate, which by default calls abort. With GCC the program prints 'terminate called after throwing an instance of' followed by the type and what() message, and the shell reports exit code 134. Whether the stack is unwound first is implementation-defined, and GCC does not unwind, so destructors of local objects do not run.
What happens if a noexcept function throws?
The exception never reaches any caller's catch block. As soon as it tries to leave the noexcept function, std::terminate is called and the program aborts. GCC warns at compile time with -Wterminate when it can see a throw inside a noexcept function.