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:
optional<T>stores the value inside itself (no heap allocation for most types) — it issizeof(T) + 1bytes (approximately). No dynamic allocation, nonullptrdereference risk.*optdereferences without checking — undefined behavior if empty, just like dereferencing a null pointer. Always checkopt.has_value()orif (opt)first, or useopt.value()which throws, oropt.value_or(default)which is safe.emplace(args...)constructs the value in-place inside the optional — more efficient thanopt = T(args...)which constructs a temporary and moves it.- Chaining: each step returns
optionalso you can propagate “not found” through multiple lookups. This is the “railway-oriented programming” pattern — computations either succeed and chain, or fail and propagatenullopt. C++23 addsand_then,transform, andor_elsefor monadic chaining. value_or(default)is the single most ergonomic way to use an optional: “give me the value or this fallback.” It avoids theif/elsestructure 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:
std::visit(visitor, variant)callsvisitorwith 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 overswitch (tag).- The Overload pattern (
struct Overload : Ts...) creates a visitor from multiple lambdas, one per type. Each lambda handles a specific type, and theusing Ts::operator()...brings all call operators into scope. This is the idiomatic way to write visitors in C++17. 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.get_if<T>(&variant)returns aT*if the variant holdsT, ornullptrotherwise. It is the non-throwing alternative toget<T>()for when you do not know the active type.holds_alternative<T>(variant)is a predicate: is the active typeT? Use it for simple type checks; usevisitwhen 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:
std::anystores 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.any_cast<T>(a)throwsbad_any_castifadoesn’t hold typeT.any_cast<T>(&a)returnsT*(non-throwing, returnsnullptron mismatch). Always use the pointer form when you’re not sure of the type.a.type()returns aconst std::type_info&— the runtime type information of the held value. The.name()is implementation-defined (often mangled on GCC/Clang).PropertyBagcombinesany(for heterogeneous storage) withoptional<T>(for safe retrieval) — a pattern that provides type safety at the access point while allowing heterogeneous storage.- The event system shows
any‘s strength: events carry strongly-typed payloads (MouseClick,KeyPress,Resize) stored asany. 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.




