optional, variant and any (C++17)
Reviewed & published by Brayan K
C++17 added three vocabulary types that let you express common shapes of data precisely: std::optional<T> for a value that might be absent, std::variant<...> for a value that is one of several known types, and std::any for a single value of a type decided at runtime. By the end you'll know which one fits each problem — and why variant usually beats any.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Model a maybe-a-value with std::optional and std::nullopt
- Access optionals safely with has_value, value_or, * and ->
- Hold one of several known types with std::variant (a type-safe union)
- Probe and extract with holds_alternative, get, get_if and index
- Dispatch on the active alternative with std::visit and monostate
- Store any type with std::any and any_cast — and know when to avoid it
💡 Real-World Analogy
Picture a small parcel locker. std::optional is a single box that is either empty or holds one item — you check before reaching in. std::variant is a box with a labelled slot list: it can hold a coin, a letter, or a key, but only one at a time, and the label always tells you which, so you never grab the wrong thing. std::any is a magic box that swallows anything at all — flexible, but you must remember exactly what you put in to get it back out, and guessing wrong throws it on the floor. Most of the time you know the shortlist of things you store, so the labelled box (variant) is the right tool.
1. std::optional — Maybe a Value
A function that might not have an answer used to return a sentinel (-1, an empty string) or take an out-parameter. std::optional<T> says it directly: the result is either engaged (holds a T) or disengaged (empty, written std::nullopt). Test it with has_value() or the implicit bool conversion, read it with * or ->, and supply a fallback with value_or(). Beware: calling .value() on an empty optional throws std::bad_optional_access.
#include <iostream>
#include <optional>
#include <string>
using namespace std;
// std::optional<T> models "maybe a value". Here a lookup either finds
// a user name or finds nobody at all -- no magic sentinel needed.
optional<string> findUser(int id) {
if (id == 1) return string("Ada"); // engaged: has a value
return nullopt; // disengaged: empty
}
int main() {
auto a = findUser(1);
auto b = findUser(99);
// operator bool / has_value() tests presence:
if (a.has_value()) cout << "found: " << *a << endl; // found: Ada
if (!b) cout << "no user 99" << endl; // no user 99
// value_or supplies a fallback and never throws:
cout << "name: " << b.value_or("(unknown)") << endl; // name: (unknown)
// operator-> reaches members of the contained value:
cout << "length: " << a->size() << endl; // length: 3
return 0;
}
// ✅ Expected output:
// found: Ada
// no user 99
// name: (unknown)
// length: 3Your turn. The division below can fail when the divisor is zero. Fill in the blank to print a fallback of -1 using value_or:
#include <iostream>
#include <optional>
using namespace std;
// safeDivide returns a result only when the divisor is non-zero.
optional<int> safeDivide(int a, int b) {
if (b == 0) return nullopt; // no answer
return a / b;
}
int main() {
// 🎯 YOUR TURN — handle the empty case with a fallback.
auto r = safeDivide(10, 0);
// Print the result, or -1 if there is no value.
// 1) Use value_or to supply the fallback:
cout << "result: " << ___ << endl; // 👉 r.value_or(-1)
// ✅ Expected output: result: -1
return 0;
}2. std::variant — One of Several Known Types
A std::variant<A, B, C> is a type-safe union: it holds a value of exactly one of its alternatives at any moment, and it always remembers which (its index()). Unlike a raw union, it tracks the active type for you and refuses unsafe access. Ask holds_alternative<T>(v) which type is active, read it with get<T>(v) (which throws std::bad_variant_access on a mismatch) or probe safely with get_if<T>(&v) (which returns a pointer, or nullptr).
#include <iostream>
#include <variant>
#include <string>
using namespace std;
// std::variant holds EXACTLY ONE of its listed types at a time.
// Think of it as a type-safe union the compiler keeps honest.
int main() {
variant<int, string> v = 42; // currently holds an int
// holds_alternative asks which type is active:
if (holds_alternative<int>(v))
cout << "int: " << get<int>(v) << endl; // int: 42
v = string("hello"); // now it holds a string
// get_if takes a pointer and returns nullptr on mismatch (no throw):
if (auto p = get_if<string>(&v))
cout << "string: " << *p << endl; // string: hello
// get<int> would now THROW std::bad_variant_access, because the
// active alternative is string, not int. get_if is the safe probe.
cout << "index: " << v.index() << endl; // index: 1
return 0;
}
// ✅ Expected output:
// int: 42
// string: hello
// index: 1When you need to act on whatever the variant holds, reach for std::visit. It applies a callable to the active alternative and, crucially, won't compile unless you handle every type — so adding an alternative later forces you to update the code. The common idiom bundles one lambda per type into an overloaded set:
#include <iostream>
#include <variant>
#include <string>
using namespace std;
// An "overloaded" helper bundles several lambdas into one visitor:
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main() {
variant<int, double, string> values[] = { 7, 3.5, string("hi") };
// std::visit calls the matching lambda for the ACTIVE alternative.
// The compiler checks every type is handled -- miss one and it fails.
for (auto& v : values) {
visit(overloaded{
[](int i) { cout << "int " << i << endl; },
[](double d) { cout << "double " << d << endl; },
[](const string& s){ cout << "string " << s << endl; },
}, v);
}
// Output:
// int 7
// double 3.5
// string hi
return 0;
}
// ✅ Expected output:
// int 7
// double 3.5
// string hiA variant is default-constructed to its first alternative, which must therefore be default-constructible. When your real types aren't — or when you want an explicit "nothing chosen yet" state — list std::monostate first:
#include <iostream>
#include <variant>
#include <string>
using namespace std;
struct Connection { // NOT default-constructible on purpose
string host;
explicit Connection(string h) : host(move(h)) {}
};
// monostate sits FIRST so the variant has a valid default ("not set yet"),
// even though Connection itself cannot be default-constructed.
int main() {
variant<monostate, Connection> state; // defaults to monostate
if (holds_alternative<monostate>(state))
cout << "state: not connected" << endl; // state: not connected
state = Connection{"example.com"};
if (auto* c = get_if<Connection>(&state))
cout << "connected to " << c->host << endl; // connected to example.com
return 0;
}
// ✅ Expected output:
// state: not connected
// connected to example.com🧩 Reorder Challenge
These lines should make a variant, switch it to a string, and safely print it. Put them in the right order:
A. v = string("hi");
B. variant<int, string> v = 1;
C. if (auto p = get_if<string>(&v)) cout << *p;
D. int main() {
E. return 0;
F. }Correct order: D → B → A → C → E → F
Open main (D), create the variant holding an int (B), reassign it to a string (A), then probe with get_if and print (C), then return 0; (E) and close the brace (F).
3. std::any — A Single Value of Any Type
std::any stores a single value whose type is chosen at runtime. It is the most flexible of the three and the least checked: you must recover the exact type with std::any_cast<T>, and a wrong guess throws std::bad_any_cast (the pointer form returns nullptr instead). Because it type-erases and may allocate, any is slower and offers no compile-time safety — so prefer variant whenever the candidate types are known, and keep any for genuinely open-ended storage like a heterogeneous property bag.
#include <iostream>
#include <any>
#include <string>
using namespace std;
// std::any erases the type: it can hold a value of ANY type, chosen at
// runtime. You must know the type again to get it back via any_cast.
int main() {
any a = 42; // holds an int right now
cout << any_cast<int>(a) << endl; // 42
a = string("text"); // now holds a string
cout << any_cast<string>(a) << endl; // text
// any_cast with the WRONG type throws std::bad_any_cast:
try {
cout << any_cast<int>(a) << endl; // a holds a string!
} catch (const bad_any_cast& e) {
cout << "bad cast: not an int" << endl; // bad cast: not an int
}
// The pointer form returns nullptr instead of throwing:
if (auto* p = any_cast<string>(&a))
cout << "still a string: " << *p << endl; // still a string: text
return 0;
}
// ✅ Expected output:
// 42
// text
// bad cast: not an int
// still a string: text🔎 Deep Dive: which type fits which problem
optional answers "is there a value?" — one type, present or absent. variant answers "which of these types is it?" — a closed, compile-time-known set. any answers "some value, type decided later" — an open set, checked only at runtime. Reach down this list in order: most problems are an optional or a variant; any is the rare last resort.
A handy combination is std::optional<std::variant<...>>: a result that might be absent, and when present is one of several types. And note that std::variant never heap-allocates for its alternatives, while std::any may — another reason to prefer variant on hot paths.
optional<int> v1; // maybe an int
variant<int, string> v2; // an int OR a string (known set)
any v3; // literally anything (decided at runtime)
// guideline: optional -> variant -> any, only as flexibility forces youPro Tips
- 💡 Prefer value_or over .value() when an empty optional is normal — it never throws.
- 💡 Use get_if in if-statements to probe a variant without risking bad_variant_access.
- 💡 Make std::visit your default for variants — it forces you to handle every alternative.
- 💡 Reach for variant before any: if you can list the types, you gain speed and compile-time safety.
🧠 Quick Recall
Predict the result before revealing the answer.
1. What does this print?
optional<int> o = nullopt;
cout << o.value_or(-1);-1 — the optional is empty, so value_or returns the fallback.
2. What does get<int>(v) do here?
variant<int, string> v = string("x");
cout << get<int>(v); // ?It throws std::bad_variant_access — the active alternative is a string, not an int. Use get_if to probe safely.
3. Why list std::monostate first in a variant?
variant<monostate, Connection> s;So the variant has a valid default-constructible first alternative and a clear "nothing chosen yet" state, even when the real type isn't default-constructible.
Common Errors (and the fix)
- Dereferencing an empty optional: *opt or .value() on a disengaged optional is undefined behaviour or a thrown bad_optional_access. Test with has_value() first, or use value_or().
- Using get on the wrong alternative: get<T>(v) throws bad_variant_access when T isn't active. Use get_if<T>(&v) in an if to probe safely.
- Non-exhaustive visit: if a visitor can't handle every alternative, the code won't compile. Add a lambda (or a generic auto one) for each type.
- Wrong type in any_cast: casting to a type the any doesn't hold throws bad_any_cast. Match the stored type exactly, or use the pointer form and check for nullptr.
- Reaching for any too soon: if you can enumerate the types, a variant is safer and faster. Only use any when the set is truly open.
📋 Quick Reference
| Task | Code |
|---|---|
| Maybe a value | optional<T> o; |
| Empty optional | o = nullopt; |
| Fallback | o.value_or(def) |
| Type-safe union | variant<A, B> v; |
| Which type? | holds_alternative<A>(v) |
| Safe access | get_if<A>(&v) |
| Dispatch | visit(vis, v) |
| Any type | any a; any_cast<T>(a) |
Mini-Challenge: A Tiny JSON Value
Model a JSON-like value with a variant (using monostate for null), then print it with std::visit and an overloaded set of lambdas — one per alternative.
#include <iostream>
#include <variant>
#include <string>
using namespace std;
// 🎯 MINI-CHALLENGE: a tiny JSON-like value
// 1. Define using Json = variant<monostate, bool, double, string>;
// (monostate models JSON null).
// 2. Make a Json that holds the string "hello".
// 3. Use std::visit with an overloaded set of lambdas to print the value:
// monostate -> "null", bool -> "true"/"false",
// double -> the number, string -> the text.
// 4. Try it with a few different held types.
//
// ✅ Example output for the string case: string: hello
// your code here🎉 Lesson Complete
- ✅ std::optional<T> models a maybe-value; use nullopt, has_value and value_or
- ✅ std::variant<...> is a type-safe union — probe with holds_alternative/get_if
- ✅ std::visit dispatches on the active alternative and forces exhaustive handling
- ✅ std::monostate gives a variant a valid default / empty state
- ✅ std::any stores any type via any_cast — prefer variant when types are known
Practice quiz
What does std::optional<T> represent?
- A value that may or may not be present
- A pointer that is always valid
- A fixed-size array of T
- A thread-safe counter
Answer: A value that may or may not be present. std::optional<T> models a value that is either present or absent, replacing sentinel values or out-parameters for maybe-a-value.
Which call safely returns a fallback when an optional is empty?
- opt.value()
- *opt
- opt.value_or(fallback)
- opt.reset()
Answer: opt.value_or(fallback). value_or(fallback) returns the contained value if present, otherwise the supplied fallback — and never throws.
What happens if you call .value() on an empty std::optional?
- It is undefined behaviour
- It throws std::bad_optional_access
- It returns 0
- It returns nullptr
Answer: It throws std::bad_optional_access. Calling .value() on a disengaged optional throws std::bad_optional_access; use has_value() or value_or() to stay safe.
What is std::variant<int, std::string> best described as?
- A pointer to either type
- An array of int and string
- A container of many values
- A type-safe union holding exactly one of the listed types at a time
Answer: A type-safe union holding exactly one of the listed types at a time. std::variant is a type-safe tagged union: at any moment it holds a value of exactly one of its alternative types.
Which function tells you whether a variant currently holds a given alternative?
- std::holds_alternative<T>(v)
- v.contains<T>()
- std::any_cast<T>(v)
- v.type() == T
Answer: std::holds_alternative<T>(v). std::holds_alternative<T>(v) returns true when the variant currently holds an alternative of type T.
What does std::get_if<T>(&v) return when the variant does NOT hold T?
- It throws std::bad_variant_access
- A null pointer
- An empty optional
- A default-constructed T
Answer: A null pointer. get_if takes a pointer and returns a pointer to the value if the variant holds T, or nullptr otherwise — it never throws.
What is std::visit used for?
- Visiting every node of a tree
- Copying a variant
- Iterating a vector
- Applying a callable to whichever alternative a variant currently holds
Answer: Applying a callable to whichever alternative a variant currently holds. std::visit dispatches a visitor (often an overloaded callable) to the currently active alternative of a variant.
What is std::monostate typically used for?
- To cast between alternatives
- To make a variant thread-safe
- To give a variant a valid 'empty' or default first alternative
- To store any type
Answer: To give a variant a valid 'empty' or default first alternative. std::monostate is an empty placeholder type, commonly listed first so a variant has a default-constructible 'no value yet' state.
How do you retrieve the stored value from a std::any holding an int?
- a.value()
- any_cast<int>(a)
- a.get<int>()
- *a
Answer: any_cast<int>(a). std::any_cast<int>(a) extracts the int; a bad cast throws std::bad_any_cast (or returns nullptr for the pointer form).
When should you generally avoid std::any?
- Whenever the set of possible types is known at compile time
- When you need a maybe-value
- When performance does not matter
- When you only store ints
Answer: Whenever the set of possible types is known at compile time. If the candidate types are known, std::variant is safer and faster; std::any erases the type and should be reserved for truly open-ended storage.
Continue this course
- Previous: Numerical Computing with Eigen
- Next: string_view & Non-Owning Views — Zero-copy string parameters and the dangling-reference pitfall
- Quick reference: C++ cheat sheet
Frequently asked questions
When should I use std::optional instead of a pointer or a sentinel value?
Use std::optional<T> whenever a function may legitimately have 'no answer' — a lookup that might miss, a parse that might fail, a setting that might be unset. It is clearer and safer than returning a magic sentinel like -1 or an empty string, and safer than a raw pointer because it owns the value and cannot dangle. Reach for value_or() to supply a fallback, or has_value()/operator bool to test before dereferencing.
What is the difference between std::variant and std::any?
std::variant<A, B, C> is a closed, type-safe union: the compiler knows the exact set of types it can hold, so access is checked and there is no heap allocation for small types. std::any can hold a value of literally any type, decided at runtime, which is more flexible but slower and unchecked at compile time. Prefer variant whenever the set of possible types is known; reserve any for genuinely open-ended cases like a property bag.
Why does std::get throw but std::get_if does not?
std::get<T>(v) is the value-returning accessor: if the variant does not currently hold T it has no value to return, so it throws std::bad_variant_access. std::get_if<T>(&v) takes a pointer and returns a pointer — a valid pointer when the alternative matches, or nullptr when it does not — so it signals failure without throwing. Use get_if in an if-statement when you are unsure which alternative is active.
What does std::monostate do?
std::monostate is an empty type with no state. Because a std::variant is default-constructed to its first alternative, that alternative must be default-constructible; if your real types are not, you list std::monostate first. It also gives the variant a meaningful 'no value chosen yet' state, similar to an empty optional, so you can model an uninitialised or neutral case explicitly.
Is std::visit better than a chain of get_if checks?
Usually yes. std::visit applies a callable to whichever alternative is active and forces you to handle every type (the code will not compile if a case is missing), which makes it exhaustive and refactor-safe. A chain of get_if checks works but is easy to leave incomplete. The common idiom is an 'overloaded' set of lambdas passed to std::visit, one per alternative.