Using std::variant for Type-Safe Unions in C++

Introduced in C++17, std::variant provides a modern, type-safe alternative to traditional C-style unions by representing a type-safe tagged union. This article explains the limitations and risks of classic C-style unions, examines how std::variant automatically manages object lifetimes and tracks active types, and demonstrates the core access patterns—including std::get, std::get_if, and std::visit—that eliminate undefined behavior when working with heterogeneous data.

The Limitations of Traditional C-Style Unions

Traditional C-style unions allow multiple data types to occupy the same memory location, enabling memory optimization. However, they lack type awareness:

union Data {
    int i;
    double d;
    char str[16];
};

A raw union does not track which member is currently active. If a program writes to Data::i and subsequently reads from Data::d, the compiler permits the operation, resulting in type punning and undefined behavior. Furthermore, C-style unions cannot cleanly handle non-trivial C++ types (such as std::string or std::vector) because they do not automatically call constructors or destructors when active members change. The developer must manually invoke placement new and explicit destructors, introducing significant risk of memory leaks and memory corruption.

How std::variant Solves These Issues

std::variant is a template class that holds a value from a fixed set of types defined at compile time. It acts as a "tagged union" by internally maintaining two components: a raw storage buffer sized to hold the largest type in the list, and a discrete discriminator (index) indicating which type is currently engaged.

Key safety guarantees include:

  1. Automatic Lifetime Management: When a new value is assigned to a std::variant, it automatically calls the destructor of the currently held object before constructing the new one. When the std::variant itself goes out of scope, it cleans up whichever object is currently active.
  2. Non-Trivial Type Support: Types with complex constructors, copy semantics, and move semantics are supported without manual intervention.
  3. No Unchecked Access: Memory locations cannot be arbitrarily read as alternative types; all read operations validate the internal discriminator.

Safe Access Patterns

C++ provides multiple mechanisms to access the data inside a std::variant, each designed to prevent runtime type mismatches.

Checked Retrieval with std::get

The std::get<T> or std::get<Index> function retrieves the value stored inside the variant. If the requested type does not match the currently held type, it immediately throws a std::bad_variant_access exception rather than returning invalid data:

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

std::variant<int, std::string> data = 42;

try {
    std::cout << std::get<int>(data);          // Succeeds: prints 42
    std::cout << std::get<std::string>(data);  // Throws std::bad_variant_access
} catch (const std::bad_variant_access& e) {
    std::cerr << "Type mismatch: " << e.what() << '\n';
}

Non-Throwing Inspection with std::get_if

For code that avoids exceptions, std::get_if<T> takes a pointer to a std::variant and returns a pointer to the value if the type matches, or nullptr if it does not:

if (const int* val = std::get_if<int>(&data)) {
    std::cout << "Integer value: " << *val << '\n';
} else {
    std::cout << "Variant does not contain an int.\n";
}

Comprehensive Handling with std::visit

The most idiomatic and compile-time-safe method for processing std::variant is std::visit. It uses a callable visitor (such as an overloaded lambda) to handle all possible types. The compiler enforces that every type declared in the variant can be handled by the visitor:

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

std::variant<int, double, std::string> item = "Hello";

std::visit(overloaded {
    [](int arg) { std::cout << "int: " << arg << '\n'; },
    [](double arg) { std::cout << "double: " << arg << '\n'; },
    [](const std::string& arg) { std::cout << "string: " << arg << '\n'; }
}, item);

If a case is omitted from the visitor, compilation fails, ensuring that unhandled types cannot cause unexpected runtime states.

The "Valueless by Exception" Guarantee

In rare cases, if an exception is thrown during an in-place assignment or type transition, a std::variant can enter a state known as valueless_by_exception(). In this state, it holds no value. Calling std::get on a valueless variant throws std::bad_variant_access, and std::visit throws the same exception, preventing access to corrupted or uninitialized memory.