If you've ever written a command-line tool in C++, you've hit this moment: a file won't open, a flag is malformed, a config value is garbage — and now what? Do you throw? Do you return some sentinel value and hope everyone remembers to check it? C++23 gave us a new option, std::expected, and it's changed how a lot of people answer that question.
This article walks through both approaches, explains the tradeoffs in plain English, and shows real code so you can decide for yourself.
The Core Idea, In One Sentence
Exceptions say "something went wrong, stop everything, and let some code way up the call stack deal with it."
std::expected says "this function might fail, so its return type honestly says so — you can't ignore it without looking silly."
That's really the whole philosophical difference. Everything else is details.
A Mental Model: The Post Office vs. The Vending Machine
Think of exceptions like mailing a letter. You drop it in the mailbox (throw), and it travels up, up, up until someone opens it (a catch block). If nobody's listening at any level, the letter just... crashes your program. You don't know who's going to read it when you write it.
std::expected is more like a vending machine. You put in your coins (call the function), and instead of maybe getting a snack and maybe getting nothing with no explanation, the machine hands you back either a snack or a little printed receipt that says "Sorry, out of Doritos." You always get something back, and you have to look at it to know which one you got.
For a CLI tool — where most "failures" are just normal, expected life events (missing file, bad argument, no permissions) — the vending machine model tends to fit better than the mailbox model.
Why This Matters More for CLI Tools Specifically
CLI tools live and die by their error messages. A user typing mytool --input foo.txt when foo.txt doesn't exist isn't triggering some exceptional, alien event — that's a Tuesday. It's expected (pun very much intended) that files might be missing, arguments might be malformed, and permissions might be wrong.
Exceptions were designed for genuinely exceptional situations — things you don't expect to handle at every call site. But "file not found" in a CLI tool is often just... a state you need to react to immediately, print a clean message, and exit with a proper status code. That's a strong hint std::expected might be the more natural fit.
Show Me the Code: The Exception Way
Let's say we're writing a tool that reads a config file and parses a port number out of it.
#include <fstream>
#include <stdexcept>
#include <string>
#include <iostream>
int read_port_from_config(const std::string& path) {
std::ifstream file(path);
if (!file) {
throw std::runtime_error("could not open config file: " + path);
}
std::string line;
std::getline(file, line);
try {
return std::stoi(line);
} catch (const std::exception&) {
throw std::runtime_error("config file does not contain a valid port number");
}
}
int main() {
try {
int port = read_port_from_config("config.txt");
std::cout << "Starting server on port " << port << "\n";
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
return 1;
}
return 0;
}
This works fine! But notice: read_port_from_config's signature — int read_port_from_config(const std::string&) — tells you nothing about the fact that it can blow up. You only find out by reading the implementation, the docs, or by getting burned in production. The failure path is invisible until it isn't.
Show Me the Code: The std::expected Way
Now the same tool, but honest about its risks:
#include <expected>
#include <fstream>
#include <string>
#include <iostream>
#include <charconv>
enum class ConfigError {
FileNotFound,
InvalidPort
};
std::string to_string(ConfigError err) {
switch (err) {
case ConfigError::FileNotFound: return "could not open config file";
case ConfigError::InvalidPort: return "config file does not contain a valid port number";
}
return "unknown error";
}
std::expected<int, ConfigError> read_port_from_config(const std::string& path) {
std::ifstream file(path);
if (!file) {
return std::unexpected(ConfigError::FileNotFound);
}
std::string line;
std::getline(file, line);
int port{};
auto result = std::from_chars(line.data(), line.data() + line.size(), port);
if (result.ec != std::errc{}) {
return std::unexpected(ConfigError::InvalidPort);
}
return port;
}
int main() {
auto result = read_port_from_config("config.txt");
if (!result) {
std::cerr << "Error: " << to_string(result.error()) << "\n";
return 1;
}
std::cout << "Starting server on port " << *result << "\n";
return 0;
}
A few things to notice here, because they're the whole point:
- The signature is honest.
std::expected<int, ConfigError>tells any caller, at a glance, "hey, I either give you anint, or I give you aConfigError. Deal with it." No hidden surprises. - You can't accidentally forget to handle it — well, you can forget to check
if (!result), but calling*resulton a failedexpectedis undefined behavior, so testing and sanitizers will find that bug fast. It's much harder to silently swallow than acatch-less exception, which just kills your whole program. - No stack unwinding. Exceptions have real runtime cost when thrown (though "zero-cost" when not thrown, on most compilers) and can complicate reasoning about what state your objects are in mid-unwind.
std::expectedis just... a return value. It's as cheap and predictable as returning a struct, because that's literally what it is.
Chaining Failures Without a Pyramid of Doom
Here's where std::expected really shines for CLI tools: pipelines. CLI tools often do a sequence of steps — parse args, read file, validate, transform, write output — where any step can fail and you want to bail out cleanly.
std::expected<Config, std::string> load_config(const std::string& path);
std::expected<Data, std::string> read_data(const Config& config);
std::expected<Report, std::string> generate_report(const Data& data);
std::expected<Report, std::string> run(const std::string& config_path) {
return load_config(config_path)
.and_then(read_data)
.and_then(generate_report);
}
and_then says "if the previous step succeeded, feed its value into the next step; if it failed, skip straight to the end carrying the error." No nested try/catch blocks, no deeply indented if chains. It reads almost like a Unix pipe: config | data | report.
The exception version of this same pipeline usually collapses into one big try block wrapping everything, which means you lose which step failed unless you're careful to attach that context yourself.
So... Should I Never Use Exceptions?
No — and here's the honest, non-dogmatic answer: use exceptions for things that are truly exceptional and unrecoverable locally, like running out of memory, or a logic error/programmer bug (std::out_of_range, failed invariants, that kind of thing). Those aren't things you want littering every function signature with error handling — you genuinely want them to propagate up and probably crash loudly in development.
Use std::expected for things that are routine, recoverable, and part of your program's normal decision tree — bad user input, missing files, network hiccups, malformed CLI arguments. Basically: "problems I fully expect to happen sometimes, and want to handle right where they occur or one level up."
For a CLI tool specifically, the vast majority of your error cases fall into that second bucket. That's why std::expected tends to feel like the better default there, while exceptions stay reserved for the rare "something is deeply broken" case.
Quick Cheat Sheet
| Exceptions | std::expected |
|
|---|---|---|
| Visible in function signature? | No | Yes |
| Cost when no error occurs | ~Free (zero-cost model) | Free (it's just a return value) |
| Cost when error occurs | Stack unwinding, relatively slow | Same as any normal return |
| Can be silently ignored? | Yes, if uncaught, it crashes the program instead | Harder — must check or risk UB |
| Good for | Bugs, truly unrecoverable states, rare edge cases | Expected, routine, recoverable failures |
| Chains nicely? | Needs try/catch nesting | and_then, or_else, transform chain cleanly |
| Requires | Any C++ standard | C++23 (or a backport library for earlier standards) |