Exceptions, optional & expected
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.
#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");
}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:
| Exception | Typical cause |
|---|---|
std::invalid_argument | A value makes no sense, such as std::stoi("abc") |
std::out_of_range | An index or number is outside the allowed range, such as v.at(99) |
std::runtime_error | A failure detected while running, such as a server not answering |
std::bad_alloc | new 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.
#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());
}
}
}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.
#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());
}
}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).
#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));
}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).
#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());
}
}
}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::optionalwhen "no value" is a normal, expected answer and needs no explanation. - Use
std::expectedwhen 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; writecatch (const std::exception& e). - Putting a general
catchfirst.catch (const std::exception&)beforecatch (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 usevalue(), which throws.
Key points
throwreports a failure;tryandcatchhandle it; catch exceptions byconstreference.- Standard exceptions such as
std::invalid_argumentandstd::out_of_rangederive fromstd::exception. - Stack unwinding destroys local objects when an exception passes, so RAII keeps cleanup automatic.
std::optional<T>holds a value or nothing;value_orand C++23transformsimplify its use.std::expected<T, E>(C++23) holds a value or an error; returnstd::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
#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());
}
}
}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?
Catching by const reference avoids copies and slicing.
2. What happens to local objects in a function when an exception leaves it?
Stack unwinding runs destructors of all local objects, which is why RAII cleanup is reliable.
3. Which type fits "a search that may find nothing, and that is normal"?
std::optional represents a value that may be absent without any error information.
4. How does a function returning std::expected<int, std::string> report an error?
std::unexpected wraps the error value so it is stored in the error slot of the expected.
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
