optional, variant, any & expected

Four modern types answer four different questions about a value's shape at compile time -- present or absent, one of several known types, literally any type, or success versus an explicit error -- replacing sentinel values and silent misuse with something the compiler can actually check.

Before C++17, a function that might not have a meaningful value to return usually communicated that with a sentinel (-1, an empty string, a null pointer) that the caller had to know to check, or with an out-parameter and a bool, or by throwing an exception for what was really a routine, expected outcome. std::optional, std::variant, std::any, and (as of C++23) std::expected are the standard library's answer: four distinct types, each modeling a different shape of 'this isn't quite a plain, always-present T,' checkable and usable through the type system itself instead of through convention and caller discipline. This page covers each in turn -- what problem it solves, how to use it safely, and where it stops being the right tool -- and closes by comparing all four directly so you can pick the right one quickly.

The Problem These Types Solve

Consider a function findUser(int id) that looks a user up by ID. What should it return if no user with that ID exists? Returning a null pointer works but forces heap allocation or a pointer to something with a longer lifetime than the function call. Returning a default-constructed User object is worse -- it's indistinguishable from a real user who happens to have all-default fields. A sentinel int like -1 for 'not found' collides with any legitimate use of -1 as a real value. Each of these four types exists to remove exactly this kind of ambiguity for a specific shape of problem.

std::optional<User> makes the possibility of absence part of the function's signature itself -- a caller can see, just from the return type, that this lookup might fail, and the type system won't let them accidentally treat a std::optional<User> as a User without explicitly unwrapping it first.

// Ambiguous and error-prone approaches:
User findUserBad1(int id);          // what does a default-constructed User mean?
User* findUserBad2(int id);         // nullptr for 'not found' -- but who owns User* otherwise?
int findAgeBad(int id);             // returning -1 for 'not found' collides with... nothing here, but often does

// Explicit, unambiguous:
std::optional<User> findUser(int id);   // clearly present-or-absent, no ownership question

std::optional -- Value or No Value

std::optional<T> (from <optional>, C++17) either holds a value of type T or holds nothing (std::nullopt). It's the direct replacement for 'return a pointer, or a sentinel value, to represent absence,' for cases where the value (when present) is an ordinary T, not something that needs to live longer than the optional itself or be shared.

An optional in a boolean context (if (auto result = ...)) is true exactly when it holds a value. result-> dereferences it like a pointer to reach the contained User's members. has_value() checks explicitly, and value_or(fallback) returns the contained value if present or the given fallback otherwise -- a clean one-liner alternative to writing an explicit if/else.

#include <iostream>
#include <optional>
#include <string>
#include <vector>

struct User { int id; std::string name; };

std::optional<User> findUser(const std::vector<User> &users, int id)
{
    for (const auto &u : users)
        if (u.id == id)
            return u;
    return std::nullopt;
}

int main()
{
    std::vector<User> users{{1, "Alice"}, {2, "Bob"}};

    if (auto result = findUser(users, 2))
        std::cout << "Found: " << result->name << "\n";

    std::optional<User> missing = findUser(users, 99);
    std::cout << "Has value: " << std::boolalpha << missing.has_value() << "\n";
    std::cout << "Name or default: " << missing.value_or(User{0, "Unknown"}).name << "\n";

    return 0;
}

std::variant -- One of Several Known Types

std::variant<T1, T2, ..., Tn> (from <variant>, C++17) holds exactly one value at a time, from a fixed set of types known at compile time. It's a type-safe alternative to a C-style union: unlike a union, a variant knows at runtime which of its alternative types is currently active, and it won't let you accidentally read the wrong one without at least an explicit, checkable attempt.

std::holds_alternative<T>(variant) checks whether T is currently the active type, and std::get<T>(variant) retrieves it -- calling std::get<T> when T isn't active throws std::bad_variant_access. std::visit is the more idiomatic and scalable approach: it applies a callable to whatever the currently-active alternative is, without you writing an explicit if/else chain checking every possible type. Here a generic lambda ([](const auto &value) {...}) handles all three alternative types with one piece of code.

#include <iostream>
#include <variant>
#include <string>

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

void printToken(const Token &t)
{
    if (std::holds_alternative<int>(t))
        std::cout << "int: " << std::get<int>(t) << "\n";
    else if (std::holds_alternative<double>(t))
        std::cout << "double: " << std::get<double>(t) << "\n";
    else
        std::cout << "string: " << std::get<std::string>(t) << "\n";
}

int main()
{
    Token a = 42;
    Token b = 3.14;
    Token c = std::string("hello");

    printToken(a);
    printToken(b);
    printToken(c);

    // std::visit applies a callable to whichever alternative is actually active:
    for (const Token &t : {a, b, c})
    {
        std::visit([](const auto &value) { std::cout << "Visited: " << value << "\n"; }, t);
    }

    return 0;
}

std::any -- Type-Erased Storage for Any Type

std::any (from <any>, C++17) can hold a value of literally any type, with no fixed set declared in advance the way std::variant requires -- the price for that flexibility is that recovering the value requires knowing (or checking) its exact type at the point you retrieve it, and there's no compile-time checking of what types might be inside at any point.

A single std::map<std::string, std::any> holds an int, a std::string, and a double side by side under one value type -- something std::variant couldn't do without declaring exactly those three types in its template parameter list up front. .type() returns a std::type_info identifying the currently-held type at runtime, and std::any_cast<T> retrieves the value, but only if T matches exactly what's stored -- a mismatched any_cast throws std::bad_any_cast.

std::any trades away almost all compile-time safety in exchange for maximum flexibility, so it's best reserved for genuinely open-ended cases -- a generic configuration system, a plugin interface passing arbitrary payloads, heterogeneous message passing -- rather than for ordinary application code where the set of possible types is actually known and fixed, which is exactly what std::variant handles with far more compile-time safety.

#include <iostream>
#include <any>
#include <string>
#include <map>

int main()
{
    std::map<std::string, std::any> settings;
    settings["width"] = 1920;
    settings["title"] = std::string("Main Window");
    settings["scale"] = 1.5;

    for (const auto &[key, value] : settings)
    {
        std::cout << key << ": ";
        if (value.type() == typeid(int))
            std::cout << std::any_cast<int>(value);
        else if (value.type() == typeid(std::string))
            std::cout << std::any_cast<std::string>(value);
        else if (value.type() == typeid(double))
            std::cout << std::any_cast<double>(value);
        std::cout << "\n";
    }

    return 0;
}

std::expected -- Success or an Explicit Error (C++23)

std::expected<T, E> (from <expected>, C++23) holds either a successful value of type T or an error of type E, making the possibility of failure part of the return type itself -- similar in spirit to Rust's Result<T, E> or Haskell's Either. It gives you an alternative to throwing exceptions for routine, expected failure modes, while still being far more structured and self-documenting than an error-code-plus-out-parameter pair.

std::unexpected(message) constructs the error branch of the expected; a plain return value constructs the success branch. result.has_value() (or a direct boolean check, like optional) tells you which branch is active, result.value() retrieves the success value, and result.error() retrieves the specific error -- something a plain std::optional<int> could never carry, since it can only say 'absent,' not why. '-5' fails at the isdigit check (the leading '-' isn't a digit) with 'input contains a non-digit character', not the value<=0 check, which is a subtlety worth noticing when reading the output.

std::expected being from C++23 means it may not be available on older compilers or standard library versions -- check your toolchain's C++23 support before relying on it in production code. Where it isn't available, std::optional paired with a separately-reported error, or std::variant<T, E>, are common fallbacks, though neither reads quite as clearly as expected's has_value()/value()/error() interface.

#include <iostream>
#include <expected>
#include <string>
#include <cctype>

std::expected<int, std::string> parsePositiveInt(const std::string &text)
{
    if (text.empty())
        return std::unexpected("input is empty");

    for (char c : text)
        if (!std::isdigit(static_cast<unsigned char>(c)))
            return std::unexpected("input contains a non-digit character");

    int value = std::stoi(text);
    if (value <= 0)
        return std::unexpected("value must be positive");

    return value;
}

int main()
{
    for (const std::string &input : {"42", "", "12a", "-5"})
    {
        auto result = parsePositiveInt(input);
        if (result.has_value())
            std::cout << "'" << input << "' -> " << result.value() << "\n";
        else
            std::cout << "'" << input << "' -> error: " << result.error() << "\n";
    }

    return 0;
}

Comparing the Four

The distinguishing question to ask is: do I know the possible types at compile time, and is 'why did this fail' information I need to carry? If there's exactly one type and the only question is presence, use optional. If there are a few known alternative types, use variant. If the type genuinely isn't known until runtime and could be anything, use any -- but treat that as a last resort, since it gives up the most compile-time safety of the four. If the operation can fail and callers need to know why, use expected instead of optional -- optional can only say 'no value,' never 'no value, and here's why.'

What is the key difference between std::optional<T> and std::expected<T, E> when representing a failed operation?

std::optional<T> can only communicate that a value is absent -- it has no way to say why. std::expected<T, E> holds either a successful T or a specific error value of type E, so callers can find out the reason for the failure (via .error()) in addition to knowing that it failed, which optional simply cannot provide.

Why does std::get<T>(someVariant) throw an exception, and what are two ways to avoid triggering that exception unnecessarily?

std::get<T> throws std::bad_variant_access if T is not the type the variant is currently holding -- there's no way for it to return a sensible value of type T if the variant doesn't currently hold one. You can check first with std::holds_alternative<T>(someVariant) before calling get, or, more scalably, use std::visit with a callable that handles every possible alternative type, which sidesteps the need to check for or guess at a specific type altogether.

When would std::any be a better choice than std::variant, and why is std::variant usually still preferred when a choice exists?

std::any is appropriate when the set of possible types genuinely isn't known in advance or must remain open-ended -- a generic plugin system or configuration store that needs to hold arbitrary, not-yet-defined types is a good fit. std::variant is preferred whenever the full set of possible types can actually be named, because it provides compile-time knowledge of the alternatives and lets std::visit be checked (and in many toolchains, warned about) for exhaustiveness, safety std::any cannot offer since it has no fixed type list at all.

What does calling operator* or operator-> on an empty std::optional actually do, and how is that different from calling .value() on it?

Calling operator* or operator-> on an empty optional is undefined behavior -- no exception is thrown, and the program's behavior is unspecified, which can silently corrupt data rather than crashing predictably. Calling .value() on an empty optional, by contrast, throws std::bad_optional_access, giving you a well-defined, catchable failure instead of undefined behavior.

In the std::expected example, why does parsing '-5' produce the error 'input contains a non-digit character' rather than 'value must be positive'?

The function checks every character with std::isdigit before ever calling std::stoi to convert the string to an int. The leading '-' character in '-5' is not a digit, so the isdigit check fails and returns the 'non-digit character' error immediately -- the positivity check never runs at all, since the function already returned by that point.

Write a function std::optional<double> safeSqrt(double x) that returns the square root of x if x is non-negative, or std::nullopt otherwise, and demonstrate calling it with both a valid and an invalid input in main().

safeSqrt returns std::nullopt for any negative input instead of returning NaN or throwing, making the possibility of failure visible directly in the return type. safeSqrt(16.0) prints 'sqrt(16) = 4', and safeSqrt(-4.0) takes the nullopt branch, printing 'sqrt(-4) is undefined for real numbers' -- the if (auto result = ...) pattern checks presence and unwraps in one line.

What happens when this code runs -- does it compile, and if so, what is the runtime behavior of each of the two std::get calls?

It compiles fine. v starts holding an int (100), is reassigned to hold a std::string ('switched') -- variants can be reassigned to any of their alternative types, and the previously active alternative (the int) is simply discarded. std::get<std::string>(v) succeeds and prints 'switched', since std::string is now the active alternative. std::get<int>(v) then throws std::bad_variant_access at runtime, because int is no longer the active type -- if uncaught, this terminates the program.

Write a program that stores a std::vector<std::any> containing an int, a double, and a std::string, then iterates over it and prints each value's type name (using typeid(...).name()) along with the value, using std::any_cast with a check via .type() before casting.

Each std::any in the vector holds a completely different underlying type, which std::any tracks internally via a std::type_info accessible through .type(). Comparing that against typeid(int), typeid(double), and typeid(std::string) in turn determines which std::any_cast<T> call is safe to make -- calling any_cast with the wrong T would throw std::bad_any_cast, so the type check comes first every time. The output prints each value's mangled type name followed by its actual value (42, 3.14, hello).

Write a function std::expected<double, std::string> safeDivide(double a, double b) using C++23's std::expected that returns an error message when b is zero, and demonstrate handling both branches with a chain of calls in main().

safeDivide returns a successful double for any non-zero denominator and std::unexpected("division by zero") otherwise, making the error an explicit, typed part of the return value rather than a thrown exception or a magic sentinel like NaN or infinity. The loop shows both branches: '10 / 2 = 5' for the success case, and '5 / 0 failed: division by zero' for the failure case, retrieved through .value() and .error() respectively based on .has_value().

Type                  Holds                                When to use
--------------------------------------------------------------------------------------
std::optional<T>      A T, or nothing                      A value that may legitimately be absent
std::variant<Ts...>   Exactly one of a FIXED, KNOWN set     A value that's one of several known shapes
std::any              Literally any type at all              A genuinely open-ended, runtime-decided type
std::expected<T, E>   A successful T, or a specific error E  An operation that can fail with a reason
std::variant<int, std::string> v = 100;
v = std::string("switched");
std::cout << std::get<std::string>(v) << "\n";
std::cout << std::get<int>(v) << "\n";