Exceptions, optional & expected

C++ · Lesson 26 of 32 6 min read Updated

Things go wrong in real programs: a user types letters where a number is expected, a file is missing, a bank server does not answer. A good program notices the problem, reports it clearly and either recovers or stops safely. C++ gives you three main tools for this. Exceptions report unexpected failures. std::optional (C++17) represents a value that may be absent. std::expected (C++23) returns either a value or an explanation of what went wrong.

In this lesson you will use all three and learn when each one fits best.

throw, try and catch

A function that cannot do its job throws an exception object. The exception travels up through the callers until a try block with a matching catch handles it. Code after the throw in that path does not run.

C++
#include <print>
#include <stdexcept>
#include <vector>

double average(const std::vector<int>& marks) {
    if (marks.empty()) {
        throw std::invalid_argument("no marks given");
    }
    int total = 0;
    for (int m : marks) {
        total += m;
    }
    return static_cast<double>(total) / marks.size();
}

int main() {
    try {
        std::println("Average: {}", average({70, 80, 90}));
        std::println("Average: {}", average({}));
        std::println("This line is skipped");
    } catch (const std::invalid_argument& e) {
        std::println("Error: {}", e.what());
    }
    std::println("Program continues");
}
Output
Average: 80
Error: no marks given
Program continues

The first call works. The second throws, so the rest of the try block is skipped and the matching catch runs. e.what() returns the message given to the exception. Always catch by const reference: it avoids copying and avoids slicing a derived exception into its base class.

Standard exception classes

The standard library has a family of exception classes in <stdexcept>, all derived from std::exception. Library functions throw them too:

ExceptionTypical cause
std::invalid_argumentA value makes no sense, such as std::stoi("abc")
std::out_of_rangeAn index or number is outside the allowed range, such as v.at(99)
std::runtime_errorA failure detected while running, such as a server not answering
std::bad_allocnew could not get memory

A try can have several catch blocks. They are checked from top to bottom, so put specific types before general ones.

C++
#include <print>
#include <stdexcept>
#include <string>

int main() {
    for (std::string text : {"42", "abc", "99999999999"}) {
        try {
            int n = std::stoi(text);
            std::println("{} -> {}", text, n);
        } catch (const std::invalid_argument&) {
            std::println("{} -> not a number", text);
        } catch (const std::out_of_range&) {
            std::println("{} -> too large for an int", text);
        } catch (const std::exception& e) {
            std::println("{} -> other error: {}", text, e.what());
        }
    }
}
Output
42 -> 42
abc -> not a number
99999999999 -> too large for an int

The program prints its own messages instead of e.what() for library exceptions, because the text of those messages differs between compilers. catch (...) with three dots catches anything, but then you cannot know what went wrong, so use it only as a last resort.

Stack unwinding and RAII

When an exception leaves a function, every local object in that function is destroyed, in reverse order, before the catch block runs. This is called stack unwinding, and it is why RAII matters so much: destructors undo partial work automatically.

C++
#include <print>
#include <stdexcept>
#include <string>

struct Step {
    std::string name;
    explicit Step(const std::string& n) : name(n) { std::println("start: {}", name); }
    ~Step() { std::println("undo: {}", name); }
};

void book_ticket() {
    Step seat("reserve seat");
    Step payment("charge card");
    throw std::runtime_error("bank server not responding");
}

int main() {
    try {
        book_ticket();
    } catch (const std::exception& e) {
        std::println("Booking failed: {}", e.what());
    }
}
Output
start: reserve seat
start: charge card
undo: charge card
undo: reserve seat
Booking failed: bank server not responding

Mark functions that can never throw with noexcept. It documents the promise and allows optimisations, as you saw with move constructors.

std::optional: a value or nothing

Sometimes "no result" is a normal answer, not an error: searching for a student who is not registered, for example. Throwing would be too heavy. std::optional<T> (header <optional>) holds either a T or nothing (std::nullopt).

C++
#include <map>
#include <optional>
#include <print>
#include <string>

std::optional<int> find_roll(const std::map<std::string, int>& reg, const std::string& name) {
    if (auto it = reg.find(name); it != reg.end()) {
        return it->second;
    }
    return std::nullopt;
}

int main() {
    std::map<std::string, int> reg = {{"Isha", 7}, {"Rohan", 12}};

    if (auto roll = find_roll(reg, "Isha")) {
        std::println("Isha has roll number {}", *roll);
    }
    std::println("Arjun: {}", find_roll(reg, "Arjun").value_or(-1));

    auto seat = find_roll(reg, "Rohan").transform([](int r) { return r + 100; });
    std::println("Rohan's exam seat: {}", seat.value_or(0));
}
Output
Isha has roll number 7
Arjun: -1
Rohan's exam seat: 112

An optional converts to true when it holds a value, *roll reads the value, and value_or supplies a fallback. C++23 added monadic functions: transform applies a function only if there is a value, and and_then and or_else chain further steps, which removes many if checks.

std::expected: a value or an error (C++23)

std::optional cannot say why there is no value. std::expected<T, E> (header <expected>) holds either a T (success) or an error of type E. Return std::unexpected(error) to report failure. It is supported from GCC 12, Clang with libc++ 16 and MSVC 19.33 (Visual Studio 2022 17.3).

C++
#include <expected>
#include <print>
#include <string>

std::expected<long long, std::string> withdraw(long long balance, long long amount) {
    if (amount <= 0) {
        return std::unexpected<std::string>("amount must be positive");
    }
    if (amount > balance) {
        return std::unexpected<std::string>("insufficient funds");
    }
    return balance - amount;
}

int main() {
    for (long long amount : {500LL, -20LL, 9000LL}) {
        auto result = withdraw(5000, amount);
        if (result) {
            std::println("Withdrew {}: new balance {}", amount, *result);
        } else {
            std::println("Withdrawal of {} failed: {}", amount, result.error());
        }
    }
}
Output
Withdrew 500: new balance 4500
Withdrawal of -20 failed: amount must be positive
Withdrawal of 9000 failed: insufficient funds

The error is part of the return type, so the caller can see from the function's declaration that it may fail, and the compiler makes it awkward to ignore. Like optional, expected has and_then, transform and or_else in C++23.

Which tool when?

  • Use exceptions for failures that are rare and that the immediate caller usually cannot fix: out of memory, a corrupt file, a broken invariant.
  • Use std::optional when "no value" is a normal, expected answer and needs no explanation.
  • Use std::expected when failure is common and the caller must handle it, such as validating user input or parsing.

Common mistakes

  • Catching by value. catch (std::exception e) copies and slices; write catch (const std::exception& e).
  • Putting a general catch first. catch (const std::exception&) before catch (const std::out_of_range&) hides the specific handler.
  • Using exceptions for normal control flow. Throwing when a search simply finds nothing is slow and unclear; return std::optional.
  • Reading an empty optional or an error-holding expected with *. That is undefined behaviour; check first, or use value(), which throws.

Key points

  • throw reports a failure; try and catch handle it; catch exceptions by const reference.
  • Standard exceptions such as std::invalid_argument and std::out_of_range derive from std::exception.
  • Stack unwinding destroys local objects when an exception passes, so RAII keeps cleanup automatic.
  • std::optional<T> holds a value or nothing; value_or and C++23 transform simplify its use.
  • std::expected<T, E> (C++23) holds a value or an error; return std::unexpected(...) for failures.

Exercise

Write std::expected<int, std::string> parse_age(const std::string& text) that uses std::stoi inside a try block. Return an error "not a number" if stoi throws, and "age must be 1 to 120" if the number is out of range. Test it with "19", "abc" and "150", printing either the age or the error.

Show solution
C++
#include <expected>
#include <print>
#include <stdexcept>
#include <string>

std::expected<int, std::string> parse_age(const std::string& text) {
    int age = 0;
    try {
        age = std::stoi(text);
    } catch (const std::exception&) {
        return std::unexpected<std::string>("not a number");
    }
    if (age < 1 || age > 120) {
        return std::unexpected<std::string>("age must be 1 to 120");
    }
    return age;
}

int main() {
    for (std::string input : {"19", "abc", "150"}) {
        if (auto age = parse_age(input)) {
            std::println("{}: age {}", input, *age);
        } else {
            std::println("{}: {}", input, age.error());
        }
    }
}
Output
19: age 19
abc: not a number
150: age must be 1 to 120

The exception from std::stoi is caught inside parse_age and turned into an error value, so callers deal with one simple result type.

Quick Quiz

1. How should you catch a standard exception?

2. What happens to local objects in a function when an exception leaves it?

3. Which type fits "a search that may find nothing, and that is normal"?

4. How does a function returning std::expected<int, std::string> report an error?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call