std::optional, std::variant, and std::any in Modern C++

C++17 introduced three vocabulary types that solve common design problems cleanly. std::optional<T> represents a value that may or may not be present — an alternative to returning sentinel values like -1 or nullptr. std::variant<T1, T2, ...> is a type-safe union that holds exactly one of several specified types. std::any holds a value of any type with type-safe retrieval. Together they replace raw pointers, error codes, void*, and unsafe C unions with expressive, safe alternatives.

Introduction

Three recurring problems appear in C++ codebases of every size and domain. The first: a function sometimes has a value to return and sometimes doesn’t — a database lookup that finds nothing, a parser that fails to parse, a search that finds no match. The traditional solutions — returning -1 as a sentinel, returning nullptr, or using an output parameter — are all unsatisfying: they require documentation to explain the sentinel, break for types that have no natural sentinel, or add verbosity.

The second: a variable might hold one of several distinct types determined at runtime — a JSON value that is a string, number, boolean, or null; a configuration entry that might be an int, string, or list; a parser token that is an identifier, literal, or operator. The traditional solution — a void* plus a type tag — is type-unsafe. Unchecked casts lead to undefined behavior that manifests as mysterious crashes.

The third: a container that stores values of completely unknown type — a property bag, a scripting engine’s value type, a message queue carrying heterogeneous payloads. Raw void* works but loses all type information and safety.

C++17 introduced three vocabulary types that solve these problems directly: std::optional<T>, std::variant<T1, T2, ...>, and std::any. This article teaches all three with practical examples — database lookups, parser implementations, configuration systems, and expression evaluators — showing when each is the right tool and how to use it idiomatically.

std::optional: Values That May Not Exist

std::optional<T> either holds a value of type T or holds nothing (std::nullopt). It models the concept of an “optional” or “maybe” value — present or absent.

Basic Usage

#include <iostream>
#include <optional>
#include <string>
#include <map>
using namespace std;

// Before std::optional: problematic return conventions
int findIndexOld(const vector<int>& v, int target) {
    for (int i = 0; i < v.size(); i++)
        if (v[i] == target) return i;
    return -1;  // Sentinel: but what if -1 is a valid index? Ambiguous.
}

string* findByKeyOld(map<string, string>& m, const string& key) {
    auto it = m.find(key);
    return (it != m.end()) ? &it->second : nullptr;  // Caller must check null
}

// With std::optional: unambiguous, safe
optional<int> findIndex(const vector<int>& v, int target) {
    for (int i = 0; i < (int)v.size(); i++)
        if (v[i] == target) return i;    // Return the value
    return nullopt;                       // Return nothing
}

optional<string> findByKey(const map<string, string>& m, const string& key) {
    auto it = m.find(key);
    if (it != m.end()) return it->second;
    return nullopt;
}

// Parse a string to int — may fail
optional<int> parseInt(const string& s) {
    try {
        size_t pos;
        int val = stoi(s, &pos);
        if (pos == s.size()) return val;  // Fully consumed — success
        return nullopt;                   // Trailing chars — failure
    } catch (...) {
        return nullopt;
    }
}

int main() {
    vector<int> data = {10, 20, 30, 40, 50};

    // Using the value
    if (auto idx = findIndex(data, 30)) {
        cout << "Found 30 at index: " << *idx << endl;
    } else {
        cout << "30 not found" << endl;
    }

    if (auto idx = findIndex(data, 99)) {
        cout << "Found 99 at index: " << *idx << endl;
    } else {
        cout << "99 not found" << endl;
    }

    // Map lookup
    map<string, string> config = {
        {"host", "localhost"},
        {"port", "8080"}
    };

    if (auto host = findByKey(config, "host")) {
        cout << "Host: " << *host << endl;
    }

    if (auto timeout = findByKey(config, "timeout")) {
        cout << "Timeout: " << *timeout << endl;
    } else {
        cout << "timeout not configured — using default 30s" << endl;
    }

    // parseInt
    for (const string& s : {"42", "3.14", "hello", "100abc", "-7"}) {
        auto result = parseInt(s);
        cout << "parseInt(\"" << s << "\") = ";
        if (result) cout << *result << endl;
        else        cout << "nullopt" << endl;
    }

    return 0;
}

Output:

Found 30 at index: 2
99 not found
Host: localhost
timeout not configured — using default 30s
parseInt("42") = 42
parseInt("3.14") = nullopt
parseInt("hello") = nullopt
parseInt("100abc") = nullopt
parseInt("-7") = -7

std::optional Interface

#include <iostream>
#include <optional>
#include <stdexcept>
using namespace std;

int main() {
    // Construction
    optional<int> empty;              // Contains nothing
    optional<int> withValue{42};      // Contains 42
    optional<int> fromNullopt = nullopt;  // Contains nothing
    auto inPlace = make_optional<string>(5, 'x');  // "xxxxx" — in-place construction

    // Checking for a value
    cout << "empty has_value:     " << empty.has_value()     << endl;  // 0
    cout << "withValue has_value: " << withValue.has_value() << endl;  // 1
    cout << "bool(withValue):     " << (bool)withValue       << endl;  // 1

    // Accessing the value
    cout << "*withValue:          " << *withValue            << endl;  // 42
    cout << "withValue.value():   " << withValue.value()     << endl;  // 42

    // value_or: default if empty
    cout << "empty.value_or(0):      " << empty.value_or(0)      << endl;  // 0
    cout << "withValue.value_or(0):  " << withValue.value_or(0)  << endl;  // 42

    // value() throws if empty
    try {
        int v = empty.value();  // Throws std::bad_optional_access
        (void)v;
    } catch (const bad_optional_access& e) {
        cout << "Caught: " << e.what() << endl;
    }

    // *empty is UB (don't do this!)
    // Modifying
    optional<string> opt;
    opt = "hello";              // Assign a value
    cout << "After assign: " << *opt << endl;

    opt.emplace(5, 'z');        // Construct in-place
    cout << "After emplace: " << *opt << endl;

    opt.reset();                // Clear the value
    cout << "After reset: " << opt.has_value() << endl;

    // Comparison
    optional<int> a{5}, b{10}, c{5}, d;
    cout << "(a == c): " << (a == c) << endl;    // 1: both hold 5
    cout << "(a == b): " << (a == b) << endl;    // 0: 5 != 10
    cout << "(a < b):  " << (a < b)  << endl;    // 1: 5 < 10
    cout << "(d == nullopt): " << (d == nullopt) << endl;  // 1
    cout << "(a == 5): " << (a == 5) << endl;    // 1: compares value

    return 0;
}

Output:

empty has_value:     0
withValue has_value: 1
bool(withValue):     1
*withValue:          42
withValue.value():   42
empty.value_or(0):      0
withValue.value_or(0):  42
Caught: bad optional access
After assign: hello
After emplace: zzzzz
After reset: 0
(a == c): 1
(a == b): 0
(a < b):  1
(d == nullopt): 1
(a == 5): 1

Chaining with optional: A Database Example

#include <iostream>
#include <optional>
#include <map>
#include <string>
using namespace std;

// Simulated database tables
map<int, string> users = {
    {1, "Alice"}, {2, "Bob"}, {3, "Carol"}
};
map<string, int> userScores = {
    {"Alice", 95}, {"Carol", 88}
};
map<int, string> trophies = {
    {95, "Gold"}, {88, "Silver"}, {72, "Bronze"}
};

optional<string> getUserName(int userId) {
    auto it = users.find(userId);
    if (it != users.end()) return it->second;
    return nullopt;
}

optional<int> getUserScore(const string& name) {
    auto it = userScores.find(name);
    if (it != userScores.end()) return it->second;
    return nullopt;
}

optional<string> getTrophy(int score) {
    auto it = trophies.find(score);
    if (it != trophies.end()) return it->second;
    return nullopt;
}

// C++23 monadic operations: and_then, transform, or_else
// For C++17, we chain manually
optional<string> getTrophyForUser(int userId) {
    auto name = getUserName(userId);
    if (!name) return nullopt;

    auto score = getUserScore(*name);
    if (!score) return nullopt;

    return getTrophy(*score);
}

int main() {
    for (int id : {1, 2, 3, 4}) {
        auto trophy = getTrophyForUser(id);
        if (trophy) {
            cout << "User " << id << " has trophy: " << *trophy << endl;
        } else {
            cout << "User " << id << ": no trophy" << endl;
        }
    }

    // value_or for defaults
    cout << "\nWith defaults:" << endl;
    for (int id : {1, 2, 3}) {
        auto name  = getUserName(id).value_or("Unknown");
        auto score = getUserScore(name).value_or(0);
        cout << name << ": score=" << score << endl;
    }
}

Output:

User 1 has trophy: Gold
User 2: no trophy
User 3 has trophy: Silver
User 4: no trophy

With defaults:
Alice: score=95
Bob: score=0
Carol: score=88

Step-by-step explanation:

  1. optional<T> stores the value inside itself (no heap allocation for most types) — it is sizeof(T) + 1 bytes (approximately). No dynamic allocation, no nullptr dereference risk.
  2. *opt dereferences without checking — undefined behavior if empty, just like dereferencing a null pointer. Always check opt.has_value() or if (opt) first, or use opt.value() which throws, or opt.value_or(default) which is safe.
  3. emplace(args...) constructs the value in-place inside the optional — more efficient than opt = T(args...) which constructs a temporary and moves it.
  4. Chaining: each step returns optional so you can propagate “not found” through multiple lookups. This is the “railway-oriented programming” pattern — computations either succeed and chain, or fail and propagate nullopt. C++23 adds and_then, transform, and or_else for monadic chaining.
  5. value_or(default) is the single most ergonomic way to use an optional: “give me the value or this fallback.” It avoids the if/else structure entirely for simple default-value patterns.

std::variant: Type-Safe Unions

std::variant<T1, T2, ...> holds exactly one value of one of the listed types at a time. It is a type-safe replacement for C-style union plus a type tag.

Basic Usage

#include <iostream>
#include <variant>
#include <string>
#include <vector>
using namespace std;

// JSON-like value type
using JsonValue = variant<
    nullptr_t,   // null
    bool,        // true / false
    int,         // integer
    double,      // floating point
    string,      // string
    vector<int>  // simplified array
>;

void printJson(const JsonValue& v) {
    // visit: call the right overload for the active type
    visit([](const auto& val) {
        using T = decay_t<decltype(val)>;
        if constexpr (is_same_v<T, nullptr_t>) cout << "null";
        else if constexpr (is_same_v<T, bool>)   cout << (val ? "true" : "false");
        else if constexpr (is_same_v<T, int>)     cout << val;
        else if constexpr (is_same_v<T, double>)  cout << val;
        else if constexpr (is_same_v<T, string>)  cout << '"' << val << '"';
        else if constexpr (is_same_v<T, vector<int>>) {
            cout << "[";
            for (size_t i = 0; i < val.size(); i++) {
                if (i) cout << ",";
                cout << val[i];
            }
            cout << "]";
        }
    }, v);
}

int main() {
    // Construction: holds the first type by default
    variant<int, string, double> v1;         // Holds int(0) — default
    variant<int, string, double> v2{42};     // Holds int 42
    variant<int, string, double> v3{"hello"};// Holds string "hello"
    variant<int, string, double> v4{3.14};   // Holds double 3.14

    // Checking the active type
    cout << "v2 index: " << v2.index() << endl;  // 0 = first type (int)
    cout << "v3 index: " << v3.index() << endl;  // 1 = second type (string)
    cout << "v4 index: " << v4.index() << endl;  // 2 = third type (double)

    cout << "v2 holds_alternative<int>:    " << holds_alternative<int>(v2)    << endl;
    cout << "v2 holds_alternative<string>: " << holds_alternative<string>(v2) << endl;

    // Getting the value
    cout << "get<int>(v2):    " << get<int>(v2)    << endl;  // 42
    cout << "get<string>(v3): " << get<string>(v3) << endl;  // "hello"

    // get with wrong type throws std::bad_variant_access
    try {
        cout << get<string>(v2) << endl;
    } catch (const bad_variant_access& e) {
        cout << "Caught: " << e.what() << endl;
    }

    // get_if: returns pointer or nullptr — non-throwing
    if (auto* p = get_if<int>(&v2)) {
        cout << "v2 is int: " << *p << endl;
    }
    if (auto* p = get_if<string>(&v2)) {
        cout << "v2 is string: " << *p << endl;
    } else {
        cout << "v2 is not a string" << endl;
    }

    // Assigning a different type
    v2 = string("world");  // Now holds string
    cout << "After assign, v2.index(): " << v2.index() << endl;  // 1

    // JSON-like values
    cout << "\n--- JSON values ---" << endl;
    vector<JsonValue> json = {
        nullptr,
        true,
        42,
        3.14,
        string("hello world"),
        vector<int>{1, 2, 3, 4, 5}
    };

    for (const auto& val : json) {
        printJson(val);
        cout << "\n";
    }

    return 0;
}

Output:

v2 index: 0
v3 index: 1
v4 index: 2
v2 holds_alternative<int>:    1
v2 holds_alternative<string>: 0
get<int>(v2):    42
get<string>(v3): hello
Caught: bad variant access
v2 is int: 42
v2 is not a string
After assign, v2.index(): 1

--- JSON values ---
null
true
42
3.14
"hello world"
[1,2,3,4,5]

std::visit: The Right Way to Use Variants

#include <iostream>
#include <variant>
#include <string>
#include <vector>
using namespace std;

// The overload pattern: a variadic struct inheriting from multiple lambdas
template<typename... Ts>
struct Overload : Ts... {
    using Ts::operator()...;
};
template<typename... Ts>
Overload(Ts...) -> Overload<Ts...>;  // Deduction guide

using Token = variant<int, double, string, char>;

// Visit with overload set: each alternative handled separately
string tokenToString(const Token& t) {
    return visit(Overload{
        [](int i)          { return "int(" + to_string(i) + ")"; },
        [](double d)        { return "double(" + to_string(d) + ")"; },
        [](const string& s) { return "string(\"" + s + "\")"; },
        [](char c)          { return string("char('") + c + "')"; }
    }, t);
}

// Visit that transforms the variant
Token doubleValue(const Token& t) {
    return visit(Overload{
        [](int i)          -> Token { return i * 2; },
        [](double d)        -> Token { return d * 2.0; },
        [](const string& s) -> Token { return s + s; },
        [](char c)          -> Token { return (char)(c + 1); }  // Next char
    }, t);
}

// Visiting two variants simultaneously
struct Calculator {
    using Number = variant<int, double>;

    static Number add(const Number& a, const Number& b) {
        return visit([](const auto& x, const auto& y) -> Number {
            // auto + auto: if either is double, result is double
            using Result = common_type_t<
                decay_t<decltype(x)>,
                decay_t<decltype(y)>
            >;
            return static_cast<Result>(x) + static_cast<Result>(y);
        }, a, b);
    }

    static void print(const Number& n) {
        visit([](const auto& v) { cout << v; }, n);
    }
};

int main() {
    // tokenToString
    vector<Token> tokens = {42, 3.14, string("hello"), 'A'};
    for (const auto& t : tokens) {
        cout << tokenToString(t) << endl;
    }

    // doubleValue
    cout << "\n--- Doubled ---" << endl;
    for (const auto& t : tokens) {
        cout << tokenToString(doubleValue(t)) << endl;
    }

    // Calculator with two-variant visit
    cout << "\n--- Calculator ---" << endl;
    Calculator::Number a{10};
    Calculator::Number b{3.14};
    Calculator::Number c{5};

    auto r1 = Calculator::add(a, b);  // int + double = double
    auto r2 = Calculator::add(a, c);  // int + int = int
    auto r3 = Calculator::add(b, b);  // double + double = double

    cout << "10 + 3.14 = "; Calculator::print(r1); cout << endl;
    cout << "10 + 5 = ";    Calculator::print(r2); cout << endl;
    cout << "3.14 + 3.14 = "; Calculator::print(r3); cout << endl;

    return 0;
}

Output:

int(42)
double(3.140000)
string("hello")
char('A')

--- Doubled ---
int(84)
double(6.280000)
string("hellohello")
char('B')

--- Calculator ---
10 + 3.14 = 13.14
10 + 5 = 15
3.14 + 3.14 = 6.28

Step-by-step explanation:

  1. std::visit(visitor, variant) calls visitor with the currently active value. The visitor must handle all types in the variant — if any type is unhandled, the code fails to compile. This compile-time exhaustiveness check is a key advantage over switch (tag).
  2. The Overload pattern (struct Overload : Ts...) creates a visitor from multiple lambdas, one per type. Each lambda handles a specific type, and the using Ts::operator()... brings all call operators into scope. This is the idiomatic way to write visitors in C++17.
  3. visit(visitor, v1, v2) visits two variants simultaneously, calling the visitor with all combinations of their types. This enables type-safe multi-dispatch without virtual functions.
  4. get_if<T>(&variant) returns a T* if the variant holds T, or nullptr otherwise. It is the non-throwing alternative to get<T>() for when you do not know the active type.
  5. holds_alternative<T>(variant) is a predicate: is the active type T? Use it for simple type checks; use visit when you need to handle multiple cases.

A Complete Parser Using variant

#include <iostream>
#include <variant>
#include <string>
#include <vector>
#include <memory>
using namespace std;

// AST node types using variant
struct NumberLit { double value; };
struct BoolLit   { bool value; };
struct StringLit { string value; };
struct NullLit   {};

struct BinaryOp;  // Forward declaration for recursive variant

using Expr = variant<
    NumberLit,
    BoolLit,
    StringLit,
    NullLit,
    shared_ptr<BinaryOp>  // Recursive via pointer
>;

struct BinaryOp {
    char op;
    Expr left;
    Expr right;
};

// Evaluate the expression
double evalExpr(const Expr& expr) {
    return visit(Overload{
        [](const NumberLit& n) { return n.value; },
        [](const BoolLit& b)   { return b.value ? 1.0 : 0.0; },
        [](const StringLit&)   { return 0.0; },
        [](const NullLit&)     { return 0.0; },
        [](const shared_ptr<BinaryOp>& op) -> double {
            double l = evalExpr(op->left);
            double r = evalExpr(op->right);
            switch (op->op) {
                case '+': return l + r;
                case '-': return l - r;
                case '*': return l * r;
                case '/': return r != 0 ? l / r : 0.0;
                default:  return 0.0;
            }
        }
    }, expr);
}

// Print the expression
string printExpr(const Expr& expr) {
    return visit(Overload{
        [](const NumberLit& n) { return to_string(n.value); },
        [](const BoolLit& b)   { return string(b.value ? "true" : "false"); },
        [](const StringLit& s) { return '"' + s.value + '"'; },
        [](const NullLit&)     { return string("null"); },
        [](const shared_ptr<BinaryOp>& op) {
            return "(" + printExpr(op->left)
                 + " " + op->op
                 + " " + printExpr(op->right) + ")";
        }
    }, expr);
}

int main() {
    // Build: (3.0 + 4.0) * 2.0
    auto add = make_shared<BinaryOp>('+', NumberLit{3.0}, NumberLit{4.0});
    auto mul = make_shared<BinaryOp>('*', add,            NumberLit{2.0});

    Expr expr{mul};
    cout << "Expression: " << printExpr(expr) << endl;
    cout << "Value:      " << evalExpr(expr)  << endl;

    // (true + 5.0) - null  => (1.0 + 5.0) - 0.0 = 6.0
    auto add2 = make_shared<BinaryOp>('+', BoolLit{true}, NumberLit{5.0});
    auto sub  = make_shared<BinaryOp>('-', add2,          NullLit{});

    Expr expr2{sub};
    cout << "\nExpression: " << printExpr(expr2) << endl;
    cout << "Value:      " << evalExpr(expr2)  << endl;
}

Output:

Expression: ((3.000000 + 4.000000) * 2.000000)
Value:      14

Expression: ((true + 5.000000) - null)
Value:      6

std::any: Type-Erased Storage

std::any can hold a value of any copyable type, with type-safe retrieval via any_cast. Unlike variant, the set of types is not specified at compile time.

#include <iostream>
#include <any>
#include <string>
#include <vector>
#include <map>
using namespace std;

// Property bag: heterogeneous key-value store
class PropertyBag {
    map<string, any> props_;
public:
    template<typename T>
    void set(const string& key, T&& value) {
        props_[key] = forward<T>(value);
    }

    template<typename T>
    optional<T> get(const string& key) const {
        auto it = props_.find(key);
        if (it == props_.end()) return nullopt;
        try {
            return any_cast<T>(it->second);
        } catch (const bad_any_cast&) {
            return nullopt;  // Wrong type requested
        }
    }

    bool has(const string& key) const {
        return props_.count(key) > 0;
    }

    const type_info& typeOf(const string& key) const {
        return props_.at(key).type();
    }

    void print() const {
        for (const auto& [key, val] : props_) {
            cout << "  " << key << ": type=" << val.type().name() << endl;
        }
    }
};

int main() {
    // Basic any usage
    cout << "=== std::any basics ===" << endl;
    any a;
    cout << "Empty has_value: " << a.has_value() << endl;

    a = 42;                // Holds int
    cout << "Holds int: " << any_cast<int>(a) << endl;
    cout << "Type: " << a.type().name() << endl;

    a = string("hello");   // Now holds string — previous int destroyed
    cout << "Holds string: " << any_cast<string>(a) << endl;

    a = 3.14;
    // Wrong cast throws bad_any_cast
    try {
        cout << any_cast<int>(a) << endl;
    } catch (const bad_any_cast& e) {
        cout << "Caught: " << e.what() << endl;
    }

    // any_cast with pointer: non-throwing (returns nullptr on wrong type)
    if (auto* p = any_cast<double>(&a)) {
        cout << "double: " << *p << endl;
    }
    if (auto* p = any_cast<int>(&a)) {
        cout << "int: " << *p << endl;
    } else {
        cout << "Not an int" << endl;
    }

    // Reset
    a.reset();
    cout << "After reset: " << a.has_value() << endl;

    // Property bag
    cout << "\n=== PropertyBag ===" << endl;
    PropertyBag bag;
    bag.set("name",    string("Alice"));
    bag.set("age",     30);
    bag.set("salary",  95000.0);
    bag.set("active",  true);
    bag.set("scores",  vector<int>{95, 87, 92, 88});

    bag.print();

    cout << "\nReading values:" << endl;
    if (auto name = bag.get<string>("name")) {
        cout << "  name: " << *name << endl;
    }
    if (auto age = bag.get<int>("age")) {
        cout << "  age: " << *age << endl;
    }
    if (auto scores = bag.get<vector<int>>("scores")) {
        cout << "  scores: ";
        for (int s : *scores) cout << s << " ";
        cout << endl;
    }

    // Wrong type: returns nullopt
    auto wrongType = bag.get<string>("age");  // age is int, not string
    cout << "  age as string: " << (wrongType ? "found" : "not found / wrong type") << endl;

    // Absent key
    auto missing = bag.get<int>("department");
    cout << "  department: " << (missing ? to_string(*missing) : "not set") << endl;

    // Plugin/middleware system with any
    cout << "\n=== Event system with any ===" << endl;
    using EventData = any;
    struct MouseClick { int x, y; string button; };
    struct KeyPress   { char key; bool ctrl, shift; };
    struct Resize     { int width, height; };

    vector<pair<string, EventData>> eventQueue = {
        {"mouseclick", MouseClick{100, 200, "left"}},
        {"keypress",   KeyPress{'A', true, false}},
        {"resize",     Resize{1920, 1080}},
        {"mouseclick", MouseClick{300, 150, "right"}}
    };

    for (const auto& [eventType, data] : eventQueue) {
        if (eventType == "mouseclick") {
            auto& e = any_cast<const MouseClick&>(data);
            cout << "Click " << e.button << " at (" << e.x << "," << e.y << ")" << endl;
        } else if (eventType == "keypress") {
            auto& e = any_cast<const KeyPress&>(data);
            cout << "Key '" << e.key << "'"
                 << (e.ctrl ? " Ctrl" : "")
                 << (e.shift ? " Shift" : "") << endl;
        } else if (eventType == "resize") {
            auto& e = any_cast<const Resize&>(data);
            cout << "Resize to " << e.width << "x" << e.height << endl;
        }
    }

    return 0;
}

Output:

=== std::any basics ===
Empty has_value: 0
Holds int: 42
Type: i
Holds string: hello
Caught: bad any cast
double: 3.14
Not an int
After reset: 0

=== PropertyBag ===
  active: type=b
  age: type=i
  name: type=NSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE
  salary: type=d
  scores: type=St6vectorIiSaIiEE

Reading values:
  name: Alice
  age: 30
  scores: 95 87 92 88 
  age as string: not found / wrong type
  department: not set

=== Event system with any ===
Click left at (100,200)
Key 'A' Ctrl
Resize to 1920x1080
Click right at (300,150)

Step-by-step explanation:

  1. std::any stores any copyable type. Assigning a new value replaces the previous one, calling the old value’s destructor. Storage for small types may be inline (no heap allocation); larger types use dynamic allocation.
  2. any_cast<T>(a) throws bad_any_cast if a doesn’t hold type T. any_cast<T>(&a) returns T* (non-throwing, returns nullptr on mismatch). Always use the pointer form when you’re not sure of the type.
  3. a.type() returns a const std::type_info& — the runtime type information of the held value. The .name() is implementation-defined (often mangled on GCC/Clang).
  4. PropertyBag combines any (for heterogeneous storage) with optional<T> (for safe retrieval) — a pattern that provides type safety at the access point while allowing heterogeneous storage.
  5. The event system shows any‘s strength: events carry strongly-typed payloads (MouseClick, KeyPress, Resize) stored as any. The event type is the discriminator (a string here; in production code, an enum). Type-erased event systems are common in GUI frameworks, game engines, and middleware.

Choosing Between optional, variant, and any

Scenario Use Reason
Function returns a value or nothing optional<T> Single known type, may be absent
Database lookup, search, find optional<T> Result may not exist
Value that might be null optional<T> Replaces nullable pointer/sentinel
Multiple known types at runtime variant<T1,T2,...> Fixed set, type-safe dispatch
Tagged union / sum type variant<T1,T2,...> Exhaustive, compile-time checked
AST nodes, JSON values variant<T1,T2,...> Recursive types, pattern matching
Completely unknown type any Open-ended, no fixed type set
Plugin/extension systems any Types registered at runtime
Property bag / configuration any Heterogeneous key-value
Error + value (either) expected<T,E> (C++23) Alternative to optional for errors

Common Mistakes

Mistake 1: Dereferencing an empty optional.

optional<int> opt;
int val = *opt;          // UB: dereferencing empty optional — crash
int val2 = opt.value();  // throws bad_optional_access — safer
int val3 = opt.value_or(0);  // Best: always valid

Mistake 2: Using variant without visit for all types.

variant<int, string> v = "hello";
// Don't use if/else chains — miss types silently:
if (holds_alternative<int>(v)) { /* ... */ }
// If you add float to the variant, this code won't handle it

// Use visit: compiler enforces exhaustiveness:
visit([](const auto& val) { /* handles ALL types */ }, v);

Mistake 3: any_cast with the wrong exact type.

any a = 42;  // Holds int
any_cast<long>(a);       // THROWS: 42 is int, not long — must be exact
any_cast<const int&>(a); // OK: const ref to held int
any_cast<int>(a);        // OK: copy of held int

Mistake 4: Storing non-copyable types in any.

any a = unique_ptr<int>(new int(42));  // COMPILE ERROR: unique_ptr not copyable
// Use shared_ptr if you need ownership in an any:
any a2 = make_shared<int>(42);        // OK: shared_ptr is copyable

Mistake 5: Using any when variant would be better.

// If you know the types at compile time — use variant!
any val;  // Wrong: loses compile-time type checking
variant<int, string, double> val2;  // Better: known types, exhaustive visit

Conclusion

std::optional, std::variant, and std::any are C++17’s vocabulary types for three fundamental design problems. Together they replace a rogues’ gallery of unsafe C++ idioms — sentinel values, raw pointers for nullable returns, void* with type tags, and unguarded union — with safe, expressive, well-specified alternatives.

std::optional<T> is the cleanest solution for “value or nothing.” It replaces -1, nullptr, and out-parameters with a type that explicitly models optionality. Its value_or() convenience and chain-friendly semantics make code that was previously cluttered with null checks into straightforward logic.

std::variant<T1, T2, ...> is a type-safe discriminated union. Its compiler-enforced exhaustiveness through std::visit eliminates the most dangerous class of type-dispatch bugs — forgetting to handle a case when a new type is added. For AST nodes, parser tokens, configuration values, and any “one of these types” scenario, variant is the right tool.

std::any trades compile-time type safety for runtime flexibility, storing values of any copyable type with type-safe retrieval. It is appropriate when the set of types genuinely is not known at compile time — plugin systems, property bags, event payloads, and scripting engine value types.

The rule of thumb: reach for optional when a single known type may be absent; reach for variant when you have a fixed set of types determined at compile time; reach for any when the types truly cannot be enumerated at compile time. For the vast majority of cases, optional and variant cover the need with better safety and performance than any.

Hot this week

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Topics

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Coroutines in C++20: Asynchronous Programming

Master C++20 coroutines — learn co_await, co_yield, co_return, promise types, awaitables, generators, and how to build async tasks and lazy sequences without callback hell.

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

C++17 Structured Bindings: Unpacking Data

Master C++17 structured bindings — learn how to unpack tuples, pairs, arrays, and structs into named variables, use them in range-for loops, and extend them to custom types.

Related Articles

Popular Categories