Skip to main content

std::expected vs Exceptions for CLI Tools

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 signatureint 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:

  1. The signature is honest. std::expected<int, ConfigError> tells any caller, at a glance, "hey, I either give you an int, or I give you a ConfigError. Deal with it." No hidden surprises.
  2. You can't accidentally forget to handle it — well, you can forget to check if (!result), but calling *result on a failed expected is undefined behavior, so testing and sanitizers will find that bug fast. It's much harder to silently swallow than a catch-less exception, which just kills your whole program.
  3. 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::expected is 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)

Popular posts from this blog

How to Check if Someone is Connected to Your Machine in Linux

Picture this: you glance at your system monitor and notice your CPU is humming along even though you're not running anything demanding. Or maybe your internet feels sluggish for no obvious reason. A small, uneasy thought creeps in — is someone else on my machine right now? For Linux users, this isn't something you have to wonder about. Linux ships with a powerful set of built-in tools that let you see exactly who's connected, who's logged in, and what your network is doing at any given moment. You don't need to be a security expert to use them — you just need to know where to look. This guide walks you through the practical, no-nonsense steps to check for unauthorized connections on your Linux system, with real commands you can run right now. Why Monitoring Network Connections Matters Every device on a network — including your own Linux machine — communicates using an IP address. When another device or user connects to your system, that connection shows up as a trac...

How to Set Up a Linux Web Server and Host an HTML Page Easily

Setting up a web server on Linux means spending a fair amount of time in the terminal — Linux leans heavily on the command line rather than clicking through menus, so you'll be typing out instructions more often than not.  If you're new to this, it can feel a little intimidating at first, but the good news is you don't need to become a Linux wizard overnight. A handful of core commands will get you surprisingly far. A few you'll lean on constantly: cd — move between directories ls — see what's in the current directory mkdir — create a new folder nano or vim — edit files right there in the terminal sudo — run something with administrator privileges Get comfortable with these and you'll be able to navigate around, tweak configuration files, and install software without much trouble. You don't need to memorize everything — you just need to be confident enough to follow along with clear instructions, which is exactly what this guide aims to give you....

Linux Network Troubleshooting

If you've spent any time as a sysadmin — or honestly, just as someone who's had to fix their own home network at 11pm — you know that connectivity issues are one of the most common headaches out there. The good news is that a handful of core tools and a methodical approach can take you from "why isn't this working" to a root cause pretty quickly.  This guide walks through the essentials: configuring interfaces, managing routes, and diagnosing problems when things go sideways. Configuring Network Interfaces Your network interfaces are the actual bridge between your machine and the outside world, so getting them configured correctly is step one for any kind of reliable connectivity. Doing It Manually ifconfig is the old-school, tried-and-true tool for this on Unix-like systems. To see everything currently configured, run: ifconfig -a If you need to manually set up a specific interface — assigning an IP, a netmask, and bringing it online — it looks like this: ifconf...