C++ std::optional variant and any type safety modern C++
|

C++ std::optional, variant & any: C++17 Vocabulary Types Guide 2026

Why C++17 Added Vocabulary Types

Before C++17, C++ programmers used fragile patterns to express common ideas. “No value” meant returning -1, nullptr, or a separate bool flag. “One of several types” meant a raw union with manual type tracking. “Any type” meant void* with no safety. These patterns compiled fine but produced bugs that only showed up at runtime — or worse, caused undefined behavior silently.

C++17 introduced three vocabulary types that replace these patterns with type-safe alternatives. They’re called vocabulary types because they give the language standard words for concepts every programmer needs. std::optional says “maybe a value.” std::variant says “one of these types.” std::any says “something, I’ll figure out what later.” All three live in their own headers and work with move semantics, constexpr, and the rest of modern C++.

If you’ve used lambda expressions or auto type deduction, you already appreciate how modern C++ features reduce boilerplate. Vocabulary types continue that trend — they make your intent explicit at the type level, so the compiler catches mistakes before your users do.

std::optional — Maybe a Value

std::optional<T> holds either a value of type T or nothing at all. It replaces the “return -1 for not found” and “return nullptr for missing” hacks that have caused billions of bugs across software history. When a function might not have an answer, optional makes that explicit in the return type.

Creating and Using optional

#include <optional>
#include <iostream>
#include <string>
#include <vector>

// Function that might not find what you're looking for
std::optional<int> find_index(const std::vector<int>& v, int target) {
    for (size_t i = 0; i < v.size(); ++i) {
        if (v[i] == target) return static_cast<int>(i);
    }
    return std::nullopt;  // Nothing found
}

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

    // Method 1: Check with has_value()
    auto result = find_index(nums, 30);
    if (result.has_value()) {
        std::cout << "Found at index: " << result.value() << "
";
    }

    // Method 2: Use like a bool (preferred)
    if (auto idx = find_index(nums, 99)) {
        std::cout << "Found at: " << *idx << "
";
    } else {
        std::cout << "Not found
";
    }

    // Method 3: value_or for defaults
    int idx = find_index(nums, 99).value_or(-1);
    std::cout << "Index (or -1): " << idx << "
";
}

The key insight: optional is falsy when empty, truthy when holding a value. The * operator extracts the value (like a pointer), and value() does the same but throws std::bad_optional_access if empty.

optional Methods

#include <optional>
#include <iostream>

int main() {
    std::optional<std::string> name = "Alice";

    // Check state
    std::cout << name.has_value() << "
";  // true (1)

    // Access value
    std::cout << *name << "
";         // Alice (no check)
    std::cout << name.value() << "
";   // Alice (throws if empty)
    std::cout << name.value_or("???") << "
"; // Alice

    // Reset to empty
    name.reset();           // Now empty
    name = std::nullopt;    // Same thing

    // Emplace constructs in-place
    name.emplace("Bob");

    // Swap two optionals
    std::optional<std::string> other = "Charlie";
    name.swap(other);
    // name = "Charlie", other = "Bob"
}

optional with Custom Types

#include <optional>
#include <string>
#include <iostream>

struct User {
    std::string name;
    int age;
};

class Database {
public:
    std::optional<User> find_user(int id) {
        if (id == 1) return User{"Alice", 30};
        if (id == 2) return User{"Bob", 25};
        return std::nullopt;
    }
};

int main() {
    Database db;

    if (auto user = db.find_user(1)) {
        std::cout << user->name << " is " << user->age << "
";
    }

    // Chaining lookups
    auto user = db.find_user(99);
    std::string name = user ? user->name : "Unknown";
    std::cout << name << "
";
}

std::variant — Type-Safe Union

std::variant<Types...> holds exactly one value from a fixed set of types at any time. Unlike C unions (which have no safety checks and cause undefined behavior on wrong access), variant tracks which type is currently stored and prevents incorrect access at compile time or with clear runtime errors.

Creating and Accessing variant

#include <variant>
#include <string>
#include <iostream>

int main() {
    // Can hold int, double, or string
    std::variant<int, double, std::string> v;

    v = 42;
    std::cout << std::get<int>(v) << "
";     // 42
    std::cout << std::get<0>(v) << "
";       // 42 (by index)

    v = 3.14;
    std::cout << std::get<double>(v) << "
";  // 3.14

    v = std::string("hello");
    std::cout << std::get<std::string>(v) << "
";

    // Wrong type throws std::bad_variant_access
    try {
        std::get<int>(v);  // v holds string!
    } catch (const std::bad_variant_access& e) {
        std::cout << "Error: " << e.what() << "
";
    }

    // Safe access with get_if (returns pointer or nullptr)
    if (auto* p = std::get_if<std::string>(&v)) {
        std::cout << "String: " << *p << "
";
    }

    // Check which type with index()
    std::cout << "Active index: " << v.index() << "
"; // 2
}

std::visit — The Visitor Pattern

std::visit applies a callable to whatever type the variant currently holds. Combined with an overloaded lambda set, it creates elegant type-switching code.

#include <variant>
#include <string>
#include <iostream>

// Overload pattern — combine multiple lambdas into one visitor
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

using Value = std::variant<int, double, std::string>;

void print_value(const Value& v) {
    std::visit(overloaded{
        [](int i)                { std::cout << "int: " << i << "
"; },
        [](double d)             { std::cout << "double: " << d << "
"; },
        [](const std::string& s) { std::cout << "string: " << s << "
"; }
    }, v);
}

int main() {
    Value v1 = 42;
    Value v2 = 3.14;
    Value v3 = std::string("hello");

    print_value(v1);  // int: 42
    print_value(v2);  // double: 3.14
    print_value(v3);  // string: hello

    // Visit can also return values
    auto size = std::visit(overloaded{
        [](int)                  { return size_t{sizeof(int)}; },
        [](double)               { return size_t{sizeof(double)}; },
        [](const std::string& s) { return s.size(); }
    }, v3);
    std::cout << "Size: " << size << "
";
}

Variant Use Cases

#include <variant>
#include <string>
#include <vector>
#include <iostream>

// Use case 1: Error handling (like Rust's Result)
struct ParseError { std::string message; };
using ParseResult = std::variant<double, ParseError>;

ParseResult parse_number(const std::string& s) {
    try {
        return std::stod(s);
    } catch (...) {
        return ParseError{"Cannot parse: " + s};
    }
}

// Use case 2: AST nodes
struct NumberLiteral { double value; };
struct StringLiteral { std::string value; };
struct BinaryOp {
    char op;
    // In real code, these would be unique_ptr to ASTNode
};
using ASTNode = std::variant<NumberLiteral, StringLiteral, BinaryOp>;

// Use case 3: Configuration values
using ConfigValue = std::variant<int, double, std::string, bool>;

int main() {
    auto result = parse_number("3.14");
    std::visit(overloaded{
        [](double d)             { std::cout << "Parsed: " << d << "
"; },
        [](const ParseError& e)  { std::cout << "Error: " << e.message << "
"; }
    }, result);

    // Config map
    std::vector<std::pair<std::string, ConfigValue>> config = {
        {"port", 8080},
        {"host", std::string("localhost")},
        {"debug", true},
        {"rate", 0.5}
    };
}

std::any — Hold Anything

std::any stores a single value of any copyable type. Unlike variant (which knows its possible types at compile time), any is fully dynamic. This makes it less safe but more flexible — useful for plugin systems, heterogeneous containers, and bridging with dynamic languages.

#include <any>
#include <string>
#include <iostream>
#include <vector>

int main() {
    std::any a = 42;
    std::cout << std::any_cast<int>(a) << "
";  // 42

    a = std::string("hello");
    std::cout << std::any_cast<std::string>(a) << "
";

    a = 3.14;
    std::cout << std::any_cast<double>(a) << "
";

    // Wrong type throws std::bad_any_cast
    try {
        std::any_cast<int>(a);  // Holds double!
    } catch (const std::bad_any_cast& e) {
        std::cout << "Error: " << e.what() << "
";
    }

    // Check type
    std::cout << a.type().name() << "
";  // Implementation-defined
    std::cout << a.has_value() << "
";     // true

    // Reset
    a.reset();
    std::cout << a.has_value() << "
";     // false

    // Heterogeneous container
    std::vector<std::any> bag = {42, 3.14, std::string("hi"), true};
}

When to Use Each Type

Choosing between optional, variant, and any comes down to how much you know at compile time.

Use std::optional<T> when a function might not return a value — database lookups, search operations, configuration reads. It replaces the “return -1” and “return nullptr” patterns. optional is by far the most commonly used of the three.

Use std::variant<Types...> when a value can be one of a known set of types — parsing results (value or error), AST nodes, message types in a protocol. It replaces raw unions and inheritance hierarchies for simple sum types.

Use std::any sparingly — only when you truly don’t know the types at compile time. Plugin systems, property bags, and bridges to dynamically-typed languages are the main legitimate uses. If you can list the possible types, use variant instead.

Real-World Patterns

#include <optional>
#include <variant>
#include <string>
#include <map>
#include <iostream>

// Pattern 1: Chaining optionals
std::optional<std::string> get_env(const std::string& key) {
    const char* val = std::getenv(key.c_str());
    if (val) return std::string(val);
    return std::nullopt;
}

std::optional<int> get_port() {
    auto port_str = get_env("PORT");
    if (!port_str) return std::nullopt;
    try {
        return std::stoi(*port_str);
    } catch (...) {
        return std::nullopt;
    }
}

// Pattern 2: Variant-based state machine
struct Disconnected {};
struct Connecting { std::string host; int port; };
struct Connected { int socket_fd; };
struct Error { std::string message; };

using ConnectionState = std::variant<Disconnected, Connecting, Connected, Error>;

template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>;

void print_state(const ConnectionState& state) {
    std::visit(overloaded{
        [](const Disconnected&)  { std::cout << "Disconnected
"; },
        [](const Connecting& c)  { std::cout << "Connecting to " << c.host << "
"; },
        [](const Connected& c)   { std::cout << "Connected (fd=" << c.socket_fd << ")
"; },
        [](const Error& e)       { std::cout << "Error: " << e.message << "
"; }
    }, state);
}

int main() {
    int port = get_port().value_or(8080);
    std::cout << "Using port: " << port << "
";

    ConnectionState state = Disconnected{};
    print_state(state);

    state = Connecting{"localhost", 8080};
    print_state(state);

    state = Connected{42};
    print_state(state);
}

Performance Considerations

std::optional<T> adds almost zero overhead — it stores T plus one bool. For small types, the bool is typically absorbed by alignment padding. No heap allocation. No virtual dispatch. The compiler optimizes optional access to the same code as manual null checks.

std::variant stores enough space for its largest type plus a small discriminator (typically 1-4 bytes for the type index). Again, no heap allocation. std::visit is implemented as a jump table, so dispatch is O(1) but has a small constant overhead compared to if/else chains. For hot paths with few types, manual get_if checks can be faster.

std::any may heap-allocate for large types. Small types (typically ≤ 2 pointers) use an internal buffer (small buffer optimization). Type checking uses RTTI (typeid). It’s the slowest of the three — use it only when flexibility matters more than performance.

Common Mistakes

#include <optional>
#include <variant>

// MISTAKE 1: Using value() without checking
void bad_optional(std::optional<int> val) {
    int x = val.value();  // Throws if empty!
}
void good_optional(std::optional<int> val) {
    int x = val.value_or(0);  // Safe default
    // Or:
    if (val) { int y = *val; }
}

// MISTAKE 2: optional<bool> ambiguity
void tricky() {
    std::optional<bool> ob = false;
    if (ob) {
        // This runs! ob has a value (false), so it's truthy
        // ob.value() is false, but ob itself is "engaged"
    }
    // Be explicit:
    if (ob.has_value() && *ob) { /* truly true */ }
}

// MISTAKE 3: optional<reference> doesn't exist
// std::optional<int&> ref;  // Won't compile!
// Use std::optional<std::reference_wrapper<int>> instead

// MISTAKE 4: Forgetting to handle all variant types
void incomplete_visit(std::variant<int, double, std::string> v) {
    // This won't compile — visit requires all types handled:
    // std::visit([](int i) { }, v);  // Error: no match for double, string
}

Practice Exercises

Exercise 1: Write a safe_divide function that returns std::optional<double>. It should return nullopt when the divisor is zero.

Exercise 2: Create a std::variant<Circle, Rectangle, Triangle> called Shape. Write a std::visit-based area() function that computes the area for each shape type.

Exercise 3: Build a simple JSON-like value type using variant: using JsonValue = std::variant<std::nullptr_t, bool, int, double, std::string>. Write a to_string() function that converts any JsonValue to its JSON representation.

Exercise 4: Implement a find_first_match function that takes a vector of strings and a predicate, returning std::optional<std::string>. Use it to search for the first string longer than 5 characters.

C++17’s vocabulary types are among the most impactful additions to modern C++. optional alone eliminates a huge class of null-related bugs, and variant enables type-safe patterns that previously required inheritance hierarchies. Next, we’ll look at structured bindings — another C++17 feature that pairs beautifully with these types.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *