The C++ Textbook

Part 4 · The standard library

optional, variant, and expected

Types that make absence and alternatives explicit.

By the end of this chapter you can

  • Return an optional instead of a sentinel value
  • Use variant with a visitor
  • Explain when expected is better than an exception

Three types that exist to make the type system say what comments used to. std::optional<T> is a T that might not be there. std::variant<A, B> is exactly one of several types. std::expected<T, E> is a T or an explanation of why not.

They are called vocabulary types because their value is in being shared: when a signature returns std::optional<int>, every C++ programmer knows what that means without reading your documentation.

optional: absence without a sentinel

The alternative is picking a value to mean “nothing” — -1, 0, nullptr, an empty string. That works until the sentinel is a legitimate result.

A sentinel that collides with real data
#include <iostream>
#include <map>
#include <optional>
#include <string>

const std::map<std::string, int> temperatures{
    {"oslo", -5}, {"cairo", 35}, {"quito", 0}};

// Sentinel version: -1 means "not found"... and also means -1 degrees.
int lookup_sentinel(const std::string& city) {
    auto it = temperatures.find(city);
    return it == temperatures.end() ? -1 : it->second;
}

std::optional<int> lookup(const std::string& city) {
    auto it = temperatures.find(city);
    if (it == temperatures.end()) return std::nullopt;
    return it->second;
}

int main() {
    std::cout << "sentinel, oslo:    " << lookup_sentinel("oslo") << '\n';
    std::cout << "sentinel, atlantis:" << lookup_sentinel("atlantis") << "  <- same answer\n";

    std::cout << std::boolalpha;
    std::cout << "optional, oslo:     " << lookup("oslo").has_value()
              << " value " << lookup("oslo").value() << '\n';
    std::cout << "optional, atlantis: " << lookup("atlantis").has_value() << '\n';
}

Oslo at −5 degrees and a city that does not exist give the same answer from the sentinel version. No amount of documentation fixes that; only the type can.

Using one

Reading an optional safely
#include <iostream>
#include <optional>
#include <string>

std::optional<int> parse_port(const std::string& text) {
    try {
        const int value = std::stoi(text);
        if (value < 1 || value > 65535) return std::nullopt;
        return value;
    } catch (const std::exception&) {
        return std::nullopt;
    }
}

int main() {
    // 1. Test, then use — the if-with-initialiser keeps the scope tight.
    if (auto port = parse_port("8080")) {
        std::cout << "parsed " << *port << '\n';
    }

    // 2. A default for the absent case.
    std::cout << "bad input -> " << parse_port("not a number").value_or(80) << '\n';
    std::cout << "out of range -> " << parse_port("70000").value_or(80) << '\n';

    // 3. value() throws rather than being undefined.
    try {
        std::cout << parse_port("x").value() << '\n';
    } catch (const std::bad_optional_access& e) {
        std::cout << "value() threw: " << e.what() << '\n';
    }
}

*opt and opt-> are the cheap accessors and are undefined when the optional is empty — exactly like dereferencing a null pointer. value() checks and throws. Use * after testing, value() when you want the check, and value_or when a default will do.

Dereferencing an empty optional
#include <iostream>
#include <optional>

int main() {
    std::optional<int> empty;
    std::cout << "reading it anyway: " << *empty << '\n';
}

Look at what that printed, and at what nothing said about it. No warning, no sanitizer report — just 0, which is a perfectly plausible port number, age, or count. The optional’s storage was never initialised, so *empty read whatever was there, and on this run that happened to be zero.

This is the worst shape undefined behaviour takes: it looks like it worked. The sanitizers cannot help, because nothing illegal happened at the level they watch — every byte read was inside a live object. The only thing standing between you and a silent wrong answer is checking before you dereference, which is why value() and value_or exist.

The optional<bool> trap
#include <iostream>
#include <optional>

int main() {
    std::optional<bool> holds_false = false;
    std::optional<bool> nothing;

    std::cout << std::boolalpha;
    std::cout << "if (holds_false):        " << static_cast<bool>(holds_false) << '\n';
    std::cout << "holds_false.value():     " << holds_false.value() << '\n';
    std::cout << "if (nothing):            " << static_cast<bool>(nothing) << '\n';
    std::cout << "\nthe first two disagree, which is the whole trap\n";
}

variant: one of several types

A std::variant<A, B, C> holds exactly one of its alternatives, and knows which. It is the type-safe replacement for a union plus a tag you maintained by hand.

Holding one of several types
#include <iostream>
#include <string>
#include <variant>

int main() {
    std::variant<int, double, std::string> value = 42;

    std::cout << "index " << value.index() << '\n';
    std::cout << "holds int? " << std::boolalpha
              << std::holds_alternative<int>(value) << '\n';
    std::cout << "as int: " << std::get<int>(value) << '\n';

    value = std::string{"now a string"};
    std::cout << "index " << value.index() << ", value " << std::get<std::string>(value) << '\n';

    // Asking for the wrong alternative throws rather than reinterpreting bytes.
    try {
        std::cout << std::get<double>(value) << '\n';
    } catch (const std::bad_variant_access& e) {
        std::cout << "get<double> threw: " << e.what() << '\n';
    }
}

That last part is the difference from a union. A union will happily reinterpret a std::string’s bytes as a double and hand you nonsense; a variant checks.

Visiting

Testing alternatives one by one works but scales badly and silently rots when you add a type. std::visit calls a function with whichever alternative is active, and — with a generic lambda or an overload set — fails to compile if you have not handled one:

std::visit, two ways
#include <iostream>
#include <string>
#include <variant>

using Value = std::variant<int, double, std::string>;

// The overloaded idiom: a struct inheriting several lambdas' call operators.
template <class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };

std::string describe(const Value& v) {
    return std::visit(overloaded{
        [](int i)                 { return "int " + std::to_string(i); },
        [](double d)              { return "double " + std::to_string(d); },
        [](const std::string& s)  { return "string \"" + s + "\""; },
    }, v);
}

int main() {
    for (const Value& v : {Value{7}, Value{2.5}, Value{std::string{"hi"}}}) {
        std::cout << describe(v) << '\n';
    }

    // A generic lambda handles every alternative uniformly.
    Value v = 3.5;
    std::visit([](const auto& x) { std::cout << "generic: " << x << '\n'; }, v);
}

overloaded is three lines of template machinery you copy once and reuse forever; Chapter 5.6 explains the pack expansion. What matters here is the property it buys: add a fourth alternative to Value and describe stops compiling until you handle it. A chain of if (holds_alternative<...>) would have compiled and silently fallen through.

Variants are how you model a value that is genuinely one of several shapes — a JSON value, a parsed token, a state machine’s current state — without inheritance and without heap allocation.

expected: a value, or a reason

std::optional says something is missing. It does not say why, and for anything a user might need to act on, why is the important part.

Failure with an explanation
#include <expected>
#include <iostream>
#include <string>

enum class ParseError { empty, not_a_number, out_of_range };

std::string to_string(ParseError e) {
    switch (e) {
        case ParseError::empty:        return "input was empty";
        case ParseError::not_a_number: return "not a number";
        case ParseError::out_of_range: return "outside 1-65535";
    }
    return "unknown";
}

std::expected<int, ParseError> parse_port(const std::string& text) {
    if (text.empty()) return std::unexpected{ParseError::empty};

    try {
        const int value = std::stoi(text);
        if (value < 1 || value > 65535) return std::unexpected{ParseError::out_of_range};
        return value;
    } catch (const std::exception&) {
        return std::unexpected{ParseError::not_a_number};
    }
}

int main() {
    for (const std::string& input : {"8080", "", "abc", "70000"}) {
        auto result = parse_port(input);
        if (result) {
            std::cout << "\"" << input << "\" -> port " << *result << '\n';
        } else {
            std::cout << "\"" << input << "\" -> failed: " << to_string(result.error()) << '\n';
        }
    }
}

Which to use

Situation Type
A value may be absent, and why is obvious or irrelevant std::optional<T>
A value may be absent, and the caller needs the reason std::expected<T, E>
Exactly one of several unrelated types std::variant<A, B, C>
Failure is exceptional and most callers cannot handle it throw

The line between expected and an exception is about who handles it and how often. A parse failure on user input is expected — it happens constantly, the caller has a plan, and forcing a try/catch around every call is noise. Running out of memory is not: almost no caller can do anything useful, so unwinding to someone who can is right.

The practical difference is that expected puts the failure in the signature. A caller cannot ignore it without writing something that looks wrong, whereas an exception is invisible at the call site.

Chaining without a pile of ifs
#include <expected>
#include <iostream>
#include <string>

std::expected<int, std::string> to_int(const std::string& s) {
    try { return std::stoi(s); }
    catch (...) { return std::unexpected{"not a number: " + s}; }
}

std::expected<int, std::string> halve(int n) {
    if (n % 2 != 0) return std::unexpected{std::to_string(n) + " is odd"};
    return n / 2;
}

int main() {
    for (const std::string& input : {"8", "7", "x"}) {
        auto result = to_int(input)
                          .and_then(halve)
                          .transform([](int n) { return n * 10; });

        if (result) std::cout << input << " -> " << *result << '\n';
        else        std::cout << input << " -> " << result.error() << '\n';
    }
}

and_then runs the next step only on success and passes the error through; transform maps the value and leaves errors alone. The failure path is written once, at the end, rather than after every call. std::optional has the same two operations in C++23.

Check yourself

Practice