std::optional, std::variant, std::any
C++17 added three types that handle a common problem in different ways: how to represent a value that might not exist, might be one of several types, or might be any type at all. These are called vocabulary types — standard library components so fundamental that code should express itself in terms of them. Each one replaces a dangerous or error-prone pattern: sentinel values, tagged unions, and void pointers.
std::optional<T> — a nullable value
std::optional<T> either contains a value of type T, or it contains nothing (std::nullopt). It replaces sentinel values like -1, nullptr, or ""that are implicitly "no value" by convention. An optional makes the maybe-nothing case explicit in the type.
#include <optional>
// Before optional — returning -1 as sentinel (fragile, surprising):
int find_index(const std::vector<int>& v, int target) {
for (int i = 0; i < v.size(); ++i)
if (v[i] == target) return i;
return -1; // what if a real index is -1? What if T changes?
}
// With optional — the absence of a value is in the type:
std::optional<int> find_index(const std::vector<int>& v, int target) {
for (std::size_t i = 0; i < v.size(); ++i)
if (v[i] == target) return i; // implicitly wraps in optional
return std::nullopt; // explicitly no value
}
// Using the result:
auto idx = find_index(v, 42);
if (idx.has_value()) {
std::cout << "Found at " << idx.value();
}
// Or with operator bool:
if (idx) {
std::cout << "Found at " << *idx; // operator* accesses the value
}
// Or with value_or — provide a default:
std::cout << idx.value_or(-1); // prints -1 if not found
// Building optionals:
std::optional<std::string> name = "Alice"; // has value
std::optional<std::string> empty; // no value (same as std::nullopt)
auto constructed = std::make_optional(42); // has_value() == trueCalling value() on an empty optional throws std::bad_optional_access. Dereferencing with * or -> on an empty optional is undefined behavior — always check first.
std::variant<Ts...> — a type-safe union
std::variant holds exactly one value from a fixed set of types. It is a type-safe replacement for C union: you can never accidentally read the wrong type, because the variant tracks which type is currently active. This is useful for representing a value that could legitimately be one of several distinct types.
#include <variant>
// A value that is either an int, a double, or a string:
std::variant<int, double, std::string> v;
v = 42; // now holds an int
v = 3.14; // now holds a double — int is destroyed
v = "hello"; // now holds a std::string — double is destroyed
// Checking which type is active:
std::holds_alternative<std::string>(v); // true
// Accessing the active value:
std::get<std::string>(v); // "hello"
std::get<int>(v); // throws std::bad_variant_access!
// Safe access with get_if (returns pointer or nullptr):
if (auto* s = std::get_if<std::string>(&v)) {
std::cout << "string: " << *s;
}
// std::visit — apply a callable to whichever type is active:
std::visit([](auto&& val) {
std::cout << val; // works for int, double, and string
}, v);
// Visitor with overloaded lambdas (C++17 trick):
auto visitor = [](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>)
std::cout << "int: " << arg;
else if constexpr (std::is_same_v<T, double>)
std::cout << "double: " << arg;
else
std::cout << "string: " << arg;
};
std::visit(visitor, v);A common use of variant is error handling:std::variant<Result, Error> is a simpler version of std::expected (C++23) and a type-safe replacement for returning error codes alongside a value.
std::any — a type-erased value
std::any holds a value of any copyable type. Unlike variant, the set of types is not fixed at compile time — you can store anything. The trade-off is that type information is checked at runtime with any_cast, which throws if the requested type is wrong. Think of it as a type-safe void*.
#include <any>
std::any a = 42; // holds an int
a = std::string("hi"); // now holds a string — int is destroyed
a = 3.14; // now holds a double
// Check type:
a.type() == typeid(double); // true
a.has_value(); // true
// Casting — throws std::bad_any_cast on type mismatch:
std::any_cast<double>(a); // 3.14
std::any_cast<int>(a); // throws! it's a double
// Safe cast via pointer (returns nullptr on mismatch):
if (auto* d = std::any_cast<double>(&a)) {
std::cout << *d;
}
// Constructing in-place:
auto a2 = std::make_any<std::vector<int>>(3, 0); // vector{0,0,0}
// Resetting:
a.reset();
a.has_value(); // falsestd::any performs a heap allocation for values larger than a small buffer (the exact threshold is implementation-defined). It is the most flexible but least efficient and least safe of the three vocabulary types. Prefer optional or variant when the type set is known.
Choosing the right type
| Type | Use when | Cost |
|---|---|---|
| std::optional<T> | A value may or may not be present | Zero overhead — stored inline |
| std::variant<Ts...> | Value is one of N known types | Size of largest type + tag byte |
| std::any | Type is unknown at compile time | Heap alloc for large values |
Key rules to remember
Accessing an empty optional with value() throws; with operator* is UB
Always check has_value() or operator bool before dereferencing. Use value_or() to provide a fallback without checking.
std::get<T>(variant) throws bad_variant_access if T is not the active type
Use std::get_if<T>(&variant) to get a pointer that is nullptr on mismatch, or std::holds_alternative<T>(variant) to check first.
Prefer std::visit over manual type-checking for variant
visit forces you to handle every possible type at compile time. Manual get/holds_alternative chains can miss cases silently.
std::any is a last resort — prefer optional or variant when types are known
any loses compile-time type information and may heap-allocate. If you find yourself reaching for any, consider whether variant with a closed set of types is more appropriate.