C++17 optional, variant and any: Practical Patterns, Pitfalls and a Worked Example
Learn when C++17 std::optional, std::variant and std::any fit, then trace safe retrieval and fallback choices through one practical configuration example.
KnowledgeGate Team
Exam prep & CS education

std::optional, std::variant and std::any can all look like ways to store a value whose shape is uncertain. They actually model three different kinds of uncertainty. Choosing the wrong one hides useful compile-time information or makes ordinary absence difficult to express. Use the configuration values "8080", 1500, true, "safe", "request-42" and 27 to compare how each type is chosen, retrieved and explained in an interview or code review.
C++17 optional, variant and any answer three different questions
Type | Meaning | Normal retrieval |
|---|---|---|
| Zero or one | Check presence, then use |
| Exactly one active value from a closed compile-time list | Use |
| Empty, or one value whose concrete type is discovered at runtime | Use exact-type |
All three are value-owning vocabulary types, not interchangeable nullable containers.
Ask three questions in order:
Can one known type simply be absent? Choose
optional.Must the value be one of a known set, such as
int,bool, orstd::string? Choosevariant.Must infrastructure carry an open-ended payload whose producer and consumer agree on an exact runtime type? Consider
any, with weaker compile-time guarantees.
These vocabulary types belong naturally in the broader Coding & DSA route. If the language foundations need revision first, follow the C++ Tutorial learning sequence before returning to C++17 library types.

std::optional represents one known type that may be absent
This parser returns a port only when the whole input is a decimal int in the accepted range:
#include <charconv>
#include <optional>
#include <string_view>
#include <system_error>
std::optional<int> parse_port(std::string_view text) {
int value = 0;
const char* first = text.data();
const char* last = first + text.size();
auto [ptr, ec] = std::from_chars(first, last, value);
if (ec != std::errc{} || ptr != last || value < 0 || value > 65535) {
return std::nullopt;
}
return value;
}ec rejects inputs that cannot be converted or overflow int. ptr != last rejects a partial parse, while the final comparisons reject ports outside 0 to 65535.
The trace is exact. parse_port("8080") contains 8080. parse_port("80x") is disengaged because parsing stops before x, and parse_port("70000") is disengaged because 70000 > 65535. Their value_or(443) results are therefore 8080, 443 and 443.
Disengagement means only “no value”. It cannot distinguish invalid characters from an out-of-range number. Use a richer result type when that reason matters. Calling value() on a disengaged optional throws std::bad_optional_access; dereferencing it violates the access precondition. Check presence first.
std::variant keeps a closed set of alternatives type-safe
Use an explicit first state for an unset setting:
#include <string>
#include <variant>
using Setting = std::variant<std::monostate, int, bool, std::string>;
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
std::string describe(const Setting& setting) {
return std::visit(overloaded{
[](std::monostate) { return std::string{"unset"}; },
[](int value) { return std::string{"int:"} + std::to_string(value); },
[](bool value) { return value ? std::string{"bool:true"}
: std::string{"bool:false"}; },
[](const std::string& value) { return std::string{"text:"} + value; }
}, setting);
}Setting unset = std::monostate{};, Setting timeout = 1500;, Setting tls = true; and Setting mode = std::string{"safe"}; have indices 0, 1, 2 and 3. The visitor returns "unset", "int:1500", "bool:true" and "text:safe". Do not make indices durable application tags because reordering the alternatives changes them.
The variant tracks its active alternative, so no manual type tag is needed. std::get_if<int>(&mode) returns nullptr. std::get<int>(mode) is valid to compile because int is listed, but throws std::bad_variant_access because mode holds a std::string.
std::any trades exhaustiveness for an open runtime type
std::any retrieves only the exact stored type:
#include <any>
#include <cstdint>
#include <string>
#include <typeinfo>
std::any trace = std::string{"request-42"};
bool present = trace.has_value();
bool is_text = trace.type() == typeid(std::string);
auto* text = std::any_cast<std::string>(&trace);
auto* not_an_int = std::any_cast<int>(&trace);
std::any count = std::int64_t{27};
std::int64_t exact = std::any_cast<std::int64_t>(count);
auto* still_not_int = std::any_cast<int>(&count);Here present and is_text are true; text points to "request-42"; and both int pointers are nullptr. exact is 27, but any_cast never converts std::int64_t to int. The value form std::any_cast<int>(count) throws std::bad_any_cast.
This fits opaque plugin metadata or a heterogeneous property bag where code outside the container defines the type contract. It supplies no exhaustive visitor, common operation, comparison, or general serialization. For a known set such as int, bool and std::string, variant preserves more information.
Optional, variant and any in a worked configuration pipeline
For the successful record, start with port_text = "8080", Setting setting = std::string{"safe"} and std::any trace = std::string{"request-42"}.
parse_portproduces engagedoptional<int>{8080}, so the port is8080.The visitor selects the string lambda and produces
"text:safe".Pointer-form
any_cast<std::string>succeeds and produces"request-42".
The rendered record is exactly {port: 8080, setting: "text:safe", trace: "request-42"}.
For the fallback record, use port_text = "80x", Setting setting = std::monostate{} and std::any trace = std::int64_t{27}. Parsing yields std::nullopt, so the caller's value_or(443) policy selects 443. Visiting produces "unset". The text cast returns nullptr, so caller policy writes "no-text-trace". The result is {port: 443, setting: "unset", trace: "no-text-trace"}. Casting that same trace to std::int64_t would return 27.

The three fields carry different uncertainty. Port is one type plus possible absence, setting is one of four declared alternatives, and trace crosses an open runtime boundary. Safe retrieval does not choose business policy. The caller still decides fallbacks and error reporting.
C++17 vocabulary-type traps come from confusing presence, alternatives and type identity
Trap | What actually happens | Safer move |
|---|---|---|
| The condition is | Test |
Calling | A disengaged optional throws | Check |
Treating | Reordering alternatives changes the index | Visit by type or define a stable domain enum |
Calling | It throws | Use a visitor or |
Casting | Exact type matching fails | Cast the stored type, then convert explicitly |
Using | Compile-time exhaustiveness is lost | Use |
A variant can rarely become valueless_by_exception() if an exception interrupts a type-changing operation. Ordinary default construction is not valueless here: Setting{} constructs its first std::monostate. Access must follow the program's actual exception policy.
variant is strong when all value alternatives are known and visitors express the operations. A virtual base-class interface suits an extensible family of objects sharing behaviour. Object Oriented Technology Explained develops that runtime-polymorphism contrast. any is opaque storage, not a behavioural interface.
C++17 interview and exam-style questions test exact state and exact type
Representative questions ask you to choose a wrapper, predict has_value(), index(), or a cast, separate compile-time rejection from a runtime mismatch, and repair unchecked retrieval.
With
std::optional<bool> enabled = false;,static_cast<bool>(enabled)istruebecause the optional is engaged, while*enabledisfalse.With
std::variant<int, std::string> value = 7; value = std::string{"7"};, the indices are first0, then1. Afterwards,std::get_if<int>(&value)isnullptr.With
std::any number = std::int64_t{27};,std::any_cast<int>(&number) == nullptristrue, although the stored numerical value is27.
There is also an important compile-time distinction. std::get<double>(value) is ill-formed because double is not in the alternative list. std::get<int>(value) is well-formed, but throws std::bad_variant_access when the active alternative is std::string{"7"}. Explain both outcomes using “alternative list” and “active alternative”.
C++17 optional, variant and any: the short version and next step
optional<T>means perhaps oneT.variant<Ts...>means one value from a declared closed set.anymeans an opaque value requiring an exact runtime type match.
The retrieval rule is equally compact: check optional presence, visit or probe the variant's active alternative, and prefer pointer-form any_cast when mismatch is expected.
Without looking back, derive 8080 versus fallback 443, the visitor results "unset", "int:1500", "bool:true" and "text:safe", and why an int cast cannot retrieve std::int64_t{27}. For a focused next step in language concepts and coding practice, use C++ Programming. For broader practice across placement languages, use Coding For Placements. Both are optional routes after you can explain the three uncertainty models in your own words.
Keep learning
Templates in C++: Reusable Parameterized Code with Worked Examples
Learn how one C++ template produces type-specific functions and classes. Follow exact calls, calculations, deduction failures, and practical corrections.
Friend Functions in C++: Controlled Class Access with a Worked Example
See how a non-member friend function can validate and update two Wallet objects while private balances stay hidden from ordinary callers. Includes runnable C++ and an exact trace.
Data Types in C++: Fundamental and Derived Types with Worked Examples
Learn how C++ fundamental types differ from arrays, pointers, references, and function types. Trace one complete program, then test the conversion traps.
C++ Constructors and Object Initialization: Techniques, Order and a Worked Trace
Learn how C++ chooses default, parameterised, delegating and copy constructors, then trace member states and initialization order through complete code.





