C++ Vocabulary Types


  • Description: A note on C++ "vocabulary" types — std::pair, std::tuple, structured bindings, std::optional, std::variant, std::any, and std::expected (C++23)
  • My Notion Note ID: K2A-B1-14
  • Created: 2019-05-20
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Why Vocabulary Types?

"Vocabulary types" are standard library types for expressing common patterns — maybe a value, one of several types, value or error, a tuple of values. Using them in API signatures means everyone reading the code agrees on the meaning, instead of inventing ad-hoc conventions (returning -1 for failure, output parameters, magic sentinel values, etc.).


2. std::pair and std::tuple

std::pair<A, B> and std::tuple<Ts...> are heterogeneous fixed-size containers.

#include <utility>   // pair
#include <tuple>     // tuple

std::pair<std::string, int> kv{"Alice", 30};
kv.first;                    // "Alice"
kv.second;                   // 30

std::tuple<int, std::string, double> t{1, "two", 3.0};
std::get<0>(t);              // 1
std::get<1>(t);              // "two"
std::get<std::string>(t);    // "two" (by type, C++14)

auto t2 = std::make_tuple(1, "two", 3.0);    // type-deduced
auto p  = std::make_pair("a", 1);

std::tuple_size_v<decltype(t)>;  // 3

Pairs are the values inside std::map. Use them only when "the two things together" doesn't have a more meaningful name; otherwise prefer a named struct — Person{name, age} reads better than pair<string, int>.


3. Structured Bindings (C++17)

Structured bindings unpack tuples, pairs, arrays, and aggregate structs into named locals.

std::pair<int, std::string> kv{1, "one"};
auto [n, s] = kv;            // n = 1, s = "one"

std::map<std::string, int> ages = {/* ... */};
for (const auto& [name, age] : ages) {
    std::cout << name << " is " << age;
}

struct Point { int x, y, z; };
Point p{1, 2, 3};
auto [x, y, z] = p;          // works on aggregates

// const auto& and auto& are also supported
const auto& [name, age] = *ages.begin();   // no copy
auto& [a, b] = kv;                          // mutable references — write through

Structured bindings are how you idiomatically iterate maps and consume multi-return functions in modern C++.


4. std::optional<T>

std::optional<T> represents "maybe a T". It either holds a T or is empty (std::nullopt).

#include <optional>

std::optional<int> find_id(const std::string& name) {
    if (name == "Alice") return 42;
    return std::nullopt;     // or just `return {};`
}

auto id = find_id("Alice");

if (id) {                    // contextual bool: true when set
    std::cout << *id;        // dereference like a pointer
    std::cout << id.value(); // throws std::bad_optional_access if empty
}

int n = id.value_or(0);      // 42 or default
id.reset();                   // clear

// Monadic operations (C++23)
auto result = id
    .transform([](int x) { return x * 2; })            // map: optional<int> -> optional<int>
    .or_else([] { return std::optional{0}; })          // alternative if empty
    .and_then([](int x) -> std::optional<int> {        // chain that may return nullopt
        return x > 0 ? std::optional{x} : std::nullopt;
    });

Use optional<T> for:

  1. Return values that may be absent — instead of -1, nullptr, bool + output parameter.
  2. Optional members of a struct.
  3. Lazy initialization — store an empty optional, populate on first use.

Don't use it for "value or error" — use expected<T, E> (or exceptions) for that. optional only tells you "did it work", not why it didn't.


5. std::variant<T...>

std::variant<Ts...> is a type-safe union: holds exactly one value from a fixed list of alternatives, knows which one, and prevents access to the wrong type.

#include <variant>

std::variant<int, std::string, double> v;   // initialized to int{}

v = "hello";                          // now holds string
v = 3.14;                             // now holds double

v.index();                             // 2 (third alternative)
std::holds_alternative<double>(v);    // true
std::get<double>(v);                   // 3.14 (throws bad_variant_access if wrong)
std::get<2>(v);                        // 3.14 (by index)

// std::visit applies a callable to whichever alternative is held:
std::visit([](auto&& val) {
    std::cout << val;                  // works because << is defined for all 3
}, v);

// Type-specific handling via a visitor struct:
struct Visitor {
    void operator()(int x)                  { std::cout << "int: "    << x; }
    void operator()(double x)               { std::cout << "double: " << x; }
    void operator()(const std::string& s)   { std::cout << "str: "    << s; }
};
std::visit(Visitor{}, v);

Use variant for:

  1. Sum types / tagged unions — "this is one of A, B, or C."
  2. State machines — each state is a different type.
  3. Heterogeneous error returnsvariant<Success, NetworkError, ParseError>.

variant is type-safe: you cannot read a value of the wrong type without going through get (which throws on mismatch) or holds_alternative.


6. std::any

std::any (C++17) holds a value of any type, recovered with any_cast<T>. It is type-erased; implementations are encouraged to apply a small-object optimization for small, nothrow-move-constructible types (so int, double, and small structs typically avoid heap allocation), but larger or throwing-move types are stored on the heap.

#include <any>

std::any a = 42;
a = std::string("hello");
a = 3.14;

double d = std::any_cast<double>(a);    // throws bad_any_cast on type mismatch
double* p = std::any_cast<double>(&a);  // pointer overload: returns nullptr on mismatch

any is rarely the right answer. Prefer variant (when the alternatives are known at compile time) or a virtual interface (when you need polymorphism). any is mostly used by frameworks that genuinely don't know the value type — config systems, plugin APIs, scripting bridges.


7. std::expected<T, E> (C++23)

std::expected<T, E> represents "a T on success, or an E on error" — a richer alternative to optional for fallible operations. It's the modern alternative to error codes and exceptions.

#include <expected>

enum class ParseError { Empty, InvalidChar, Overflow };

std::expected<int, ParseError> parse(std::string_view s) {
    if (s.empty()) return std::unexpected{ParseError::Empty};
    int n = 0;
    for (char c : s) {
        if (!std::isdigit(c)) return std::unexpected{ParseError::InvalidChar};
        n = n * 10 + (c - '0');
    }
    return n;
}

auto r = parse("42");
if (r) {
    std::cout << *r;        // dereference like optional
}
if (!r) {
    auto err = r.error();   // access the error
}

// Monadic chaining:
auto doubled = parse("21")
    .transform([](int x) { return x * 2; })
    .or_else([](ParseError) { return std::expected<int, ParseError>{0}; });

Use expected when:

  1. The function might fail in a small number of well-defined ways.
  2. Callers should handle the error inline (not propagate via exceptions).
  3. You need the error reason, not just "failed."

See also: C++ Error Handling § 6.


8. Decision Guide

Situation Use
Return value or "missing" std::optional<T>
Return value or specific error std::expected<T, E> (C++23)
Multiple possible types from a fixed list std::variant<T...>
Two values of related kinds struct (or std::pair if names don't matter)
Several values, possibly unrelated std::tuple (or a struct)
Type genuinely unknown at compile time std::any (rare)
"Failed but with no info" bool, or optional