Designated Initializers (C++20)
Named member initialization for aggregates — initialize exactly the fields you need, in declaration order, with safe defaults for the rest.
Designated Initializerssince C++20A C++20 aggregate-initialization syntax that names each member being set with .member = value, enabling partial initialization, self-documenting call sites, and resilience to struct layout changes — without requiring a constructor.
Overview
Before C++20, initializing a struct required either positional brace-init (order-sensitive, fragile across refactors) or a hand-written constructor (boilerplate, loses aggregate semantics). Designated initializers bridge the gap: named fields without sacrificing aggregate status.
They are deliberately more restrictive than their C99 counterpart — designators must follow declaration order, array-subscript designators ([0] =) are absent, and chained designators (.a.b =) are unsupported. These constraints guarantee single-pass, left-to-right evaluation with unambiguous initialization order.
Eligibility — the type must be an aggregate (C++20 definition):
- No user-provided constructors (defaulted/deleted constructors are fine)
- No private or protected non-static data members
- No virtual functions
- No virtual base classes
- Public base classes are permitted since C++20 relaxed the aggregate definition
Syntax rules:
- Designators must appear in member declaration order — out-of-order is ill-formed, not a warning
- All designators or none — mixing designated and positional initializers is ill-formed
- Each member may appear at most once
Syntax
struct Config {
std::string host = "localhost"; // default member initializer (C++11)
uint16_t port = 8080;
bool tls = false;
int timeout_ms = 5000;
};
// Name only the members you care about; rest use their defaults
Config cfg{ .host = "api.example.com", .tls = true };
// port = 8080, timeout_ms = 5000
// Mixing designated and positional — ill-formed
// Config bad{ "api.example.com", .port = 443 }; // ERROR
// Out-of-order — ill-formed
// Config bad2{ .tls = true, .host = "x" }; // ERROR: tls declared after hostUndesignated members receive their default member initializer value if one exists; otherwise they are value-initialized — zero for scalars, {} for class types.
Examples
Named-parameter idiom
The highest-value use case: replace boolean/integer telescope parameters with a config aggregate.
struct RenderOptions {
uint32_t color = 0xFFFFFFFF;
float line_width = 1.0f;
float opacity = 1.0f;
bool anti_aliased = true;
bool dashed = false;
};
void render_polyline(std::span<const Vec2> pts, RenderOptions opts = {});
// Before: which bool is anti_aliased vs dashed?
render_polyline(pts, 0xFF0000FF, 2.0f, 1.0f, true, false);
// With designated initializers: self-documenting, immune to parameter reordering
render_polyline(pts, { .color = 0xFF0000FF, .line_width = 2.0f });Nested aggregates
C++20 does not support chained designators (.size.w = 1920). Nest a separate braced-init-list for inner aggregates instead:
struct Size { int w, h; };
struct Pos { int x, y; };
struct Window {
Size size;
Pos position;
std::string title;
bool resizable = true;
};
Window win{
.size = { .w = 1920, .h = 1080 },
.position = { .x = 0, .y = 0 },
.title = "Viewport",
// resizable uses its default: true
};Resilience to struct evolution
Adding a field to a struct silently breaks positional callers. Designated callers keep compiling — the new member gets its default:
struct Metric {
std::string name;
double value;
std::string unit = ""; // added later — designated callers are unaffected
int64_t timestamp_us = 0;
};
// Written before 'unit' and 'timestamp_us' existed — still compiles correctly
Metric m{ .name = "rtt", .value = 3.7 };Union initialization
With unions, exactly one member may be designated:
union Payload {
int32_t i32;
float f32;
uint64_t u64;
};
Payload p{ .f32 = 3.14f }; // OK — i32 and u64 are inactive members
// Payload q{ .i32 = 1, .f32 = 2.0f }; // ERROR: unions allow only one designatorAggregates with base classes (C++20)
C++20 permits aggregates with public non-virtual base classes, and designated initializers work with them — base members are initialized positionally before the derived fields:
struct Base { int id = 0; };
struct Record : Base { // aggregate with base class — valid C++20
std::string label;
double score = 0.0;
};
// Base sub-object initialized via positional brace-init; derived members designated
Record r{ {.id = 42}, .label = "test", .score = 9.5 };Best Practices
Use designated initializers for any aggregate with three or more members. The cognitive load of remembering positional order is non-trivial; named fields eliminate a class of silent transposition bugs entirely.
Pair with default member initializers. These two features are designed to work together: defaults provide safe fallbacks, designated init lets callers override only what they need. Every field in a config or options struct should have a sensible default.
// All members have defaults → callers override only what differs
struct ConnectionPool {
std::string dsn;
int min_size = 2;
int max_size = 10;
int idle_timeout_s = 60;
bool ssl = true;
};
auto pool = ConnectionPool{ .dsn = "postgres://...", .max_size = 50 };Prefer this over constructor overload sets. A constructor-per-combination approach explodes combinatorially. An options struct with designated initializers scales to dozens of parameters with zero boilerplate and full call-site clarity.
Treat field declaration order as part of the public API. Reordering members to satisfy style preferences is a source-breaking change: a compile error for designated callers whose order no longer matches, and a silent semantic change for positional callers. Don't do it without an API bump.
Common Pitfalls
Out-of-order is ill-formed, not a warning
GCC and Clang shipped C99-style designated initializer extensions before C++20 that permitted any order. Code relying on those extensions fails in strict C++20 mode (-std=c++20):
struct Point { int x, y, z; };
// Ill-formed in C++20 — z designated before y
// Point p{ .z = 3, .x = 1 }; // hard error, not a warningAlways compile with -std=c++20 -pedantic to catch these early.
Undesignated scalars are zero, not indeterminate
Undesignated members without a default member initializer are value-initialized, which means zero for scalars — not indeterminate garbage. This is almost always what you want, but if you expect a non-zero default you must provide one explicitly:
struct Rect { int x, y, w, h; };
Rect r{ .w = 640, .h = 480 };
// x == 0, y == 0 — value-initialized, not uninitializedNon-aggregate types silently rejected
Any user-provided constructor (as opposed to = default or = delete) disqualifies the type:
struct Aggregate {
Aggregate() = default; // defaulted — still an aggregate in C++20
int x = 0;
};
Aggregate a{ .x = 42 }; // OK
struct NotAggregate {
NotAggregate(int x) : x{x} {} // user-provided
int x;
};
// NotAggregate n{ .x = 42 }; // ERROR: not an aggregateThe error message varies by compiler and can be cryptic. If designated init fails unexpectedly, verify aggregate status first.
Compiler support edge cases
Core designated initializer support landed in GCC 8 (as an extension) and GCC 10 (C++20 conforming), Clang 10, and MSVC 19.21. Early implementations had divergent behavior in nested designators and interaction with CTAD (C++17). If you target C++20 across multiple compilers, exercise designated init paths in CI against all of them.
Differences from C99
| Feature | C99 | C++20 |
|---|---|---|
| Out-of-order designators | Allowed | Ill-formed |
Array subscript [N] = | Supported | Not supported |
Chained .a.b = | Supported | Not supported — use .a = { .b = ... } |
| Mix designated and positional | Allowed | Ill-formed |
| Multiple union designators | Allowed (last wins) | Ill-formed |
C++20's stricter rules are a feature: single-pass initialization with unambiguous evaluation order, which C99's permissive model cannot guarantee uniformly.
Nested Designated Initializers
Designated initializers compose naturally: initialize a nested aggregate by supplying its own designated-init list inside the outer braces. The inner type must itself be an aggregate for inner designators to be valid — otherwise plain positional brace-init applies.
// C++20
struct Point {
double x = 0.0;
double y = 0.0;
};
struct Circle {
Point center;
double radius = 1.0;
};
Circle c{ .center = { .x = 3.0, .y = 4.0 }, .radius = 5.0 };Deeper nesting works the same way, one level per set of braces. If the inner type is not an aggregate (for example, it has a user-provided constructor), use that type's own initialization syntax inside the braces instead of designated syntax.
// C++20
struct ServerConfig {
std::string host = "localhost";
uint16_t port = 8080;
};
struct AppConfig {
ServerConfig server;
int timeout = 30;
bool verbose = false;
};
AppConfig cfg{
.server = { .host = "prod.example.com", .port = 443 },
.timeout = 60,
// verbose uses its default: false
};Chain depth is unlimited, but the chained .a.b = shorthand of C99 is not available in C++20. Each nesting level requires a separate set of braces with its own designators, which keeps the syntax unambiguous regardless of depth.
Designated Initializers with Default Arguments in Functions
Designated initializers enable a "named parameters" idiom that is cleaner than long positional argument lists and more lightweight than a builder pattern. Define an options struct with sensible defaults for every field, then let callers specify only the fields they care about at the call site.
// C++20
struct SearchOptions {
int max_results = 20;
int page = 0;
bool case_sensitive = false;
bool exact_match = false;
bool verbose = false;
};
std::vector<Result> search(std::string_view query, SearchOptions opts = {});
// Caller specifies only overrides — all other options stay at their defaults
auto results = search("openssl", { .max_results = 5, .verbose = true });Compared to positional parameters (search("openssl", 5, 0, false, false, true)), the designated form self-documents every override. Compared to a builder pattern (SearchOptions{}.max_results(5).verbose(true)), no boilerplate setter methods are required and the struct remains a plain aggregate that can be stored, copied, and compared trivially.
// C++20
// Storing and reusing option sets is straightforward
constexpr SearchOptions kStrictSearch{ .case_sensitive = true, .exact_match = true };
constexpr SearchOptions kQuickSearch { .max_results = 3 };
auto r1 = search("AES", kStrictSearch);
auto r2 = search("cipher", kQuickSearch);This pattern scales gracefully: adding a new option with a default never breaks existing call sites. That property makes it the preferred approach for library APIs that evolve over time without breaking ABI.
Interaction with std::array and C-Style Arrays
std::array<T, N> is an aggregate and can be initialized with a braced-init-list, but C++20 does not support array-subscript designators ([0] = val) — that syntax is C99-only. Attempting it in C++ mode is ill-formed.
// C++20
// std::array is an aggregate — normal brace-init works
std::array<int, 5> a = { 10, 20, 30, 40, 50 }; // OK: positional
// [N] = syntax is C99; NOT valid C++20
// std::array<int, 5> b = { [0] = 10, [3] = 40 }; // ERROR in C++20The gap exists because array-subscript designators introduce non-trivial ordering semantics (sparse initialization, implicit zeroing of skipped elements) that the C++ standard committee did not include in the C++20 proposal. The C99 feature remains available only when compiling a .c file.
// C++20
// Workaround: wrap the array in a named struct so fields can be designated
struct Palette {
std::array<uint32_t, 4> colors = { 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFFFF };
};
// Now override specific entries by giving them names
struct ThemePalette {
uint32_t background = 0x1E1E1E;
uint32_t foreground = 0xD4D4D4;
uint32_t accent = 0x007ACC;
uint32_t error = 0xF44747;
};
ThemePalette tp{ .accent = 0xFF8C00, .error = 0xE51400 };When you need sparse or index-driven initialization in C++, wrapping elements in a named struct and using designated initializers is the idiomatic workaround.
Designated Initializers and Inheritance
C++20 restricts designated initializers to members declared directly in the type being initialized — base-class members cannot appear as designators in a derived-class initializer list. Attempting to do so is ill-formed.
// C++20
struct Base {
int x = 0;
int y = 0;
};
struct Derived : Base {
int z = 0;
};
// ERROR: .x and .y are Base members, not directly in Derived
// Derived d{ .x = 1, .y = 2, .z = 3 }; // ill-formedThe standard-conforming workaround is to initialize the base sub-object explicitly using a positional or designated brace-init for Base, followed by designated initializers for the derived-class members:
// C++20
// Initialize base sub-object first, then derived members
Derived d{ Base{ .x = 1, .y = 2 }, .z = 3 };
// If Base has no designated-init-worthy members (or you prefer positional):
Derived d2{ {1, 2}, .z = 3 };This restriction exists because C++20 aggregates with bases initialize the base sub-object as a single positional slot, and mixing that slot with per-member designators from the base would create ambiguous ordering. The restriction was intentional in P0329 and remains unchanged through C++23. For simple single-level inheritance hierarchies the workaround above is idiomatic and readable.
Designated Initializers for Test Fixtures
Designated initializers pair exceptionally well with unit tests: create an object with only the fields relevant to the scenario under test, leaving every other field at a known default. This eliminates the need for factory helper functions in many cases and keeps the test's intent visible at a glance.
// C++20
struct Widget {
int id = 0;
std::string label = "";
bool visible = true;
bool enabled = true;
float opacity = 1.0f;
int tab_index = -1;
std::string tooltip = "";
};
// Test only cares about id — all other fields are safely defaulted
Widget w{ .id = 42 };
assert(lookup_widget(42) == w);
// Another test only cares about visibility
Widget hidden{ .id = 7, .visible = false };
assert(!renderer.is_drawn(hidden));Compare this to the alternatives: a hand-written factory function must be updated every time the struct grows a field, and a builder pattern introduces its own boilerplate and potential for misuse. With designated initializers, the struct definition is the factory.
// C++20
// Parameterized test: vary only the relevant dimension
for (auto opacity : { 0.0f, 0.5f, 1.0f }) {
Widget w{ .id = 1, .opacity = opacity };
EXPECT_NEAR(renderer.composite_alpha(w), opacity, 1e-4f);
}The key rule: every field in a test-fixture struct should have a sensible default, so that the test body designates only the fields that drive the behavior being verified. This is the same "default everything, override only what matters" principle that makes designated initializers valuable in production APIs.
std::designated_initializer_t and Reflection (C++26 Preview)
C++26 static reflection (P2996) does not introduce std::designated_initializer_t as a named type, but it does provide the primitives to build runtime field-by-name initialization from scratch — effectively generalizing designated initializers to run time. The mechanism is the std::meta reflection library and the template for expansion syntax.
// C++26 / experimental — P2996 reflection, not yet in any released standard
#include <meta>
struct Config {
std::string host = "localhost";
uint16_t port = 8080;
int timeout = 30;
bool verbose = false;
};
// Set a struct field by name at runtime using P2996 reflection
template <typename T>
bool set_field(T& obj, std::string_view name, std::string_view value) {
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^^T)) {
if (std::meta::name_of(m) == name) {
// assign after converting value to the member's type
obj.[:m:] = parse_as<std::meta::type_of(m)>(value);
return true;
}
}
return false; // field not found
}
Config cfg;
set_field(cfg, "port", "443");
set_field(cfg, "verbose", "true");
// cfg.port == 443, cfg.verbose == trueThis pattern allows configuration files, command-line flags, or HTTP request bodies to drive struct initialization without any hand-written dispatch tables. The template for loop is a compile-time expansion over every non-static data member, so the generated code is as efficient as a manually written if/else chain.
// C++26 / experimental
// Enumerate all fields and their current values — useful for serialization
template <typename T>
void dump_fields(const T& obj) {
template for (constexpr auto m : std::meta::nonstatic_data_members_of(^^T)) {
std::println(" {}: {}", std::meta::name_of(m), obj.[:m:]);
}
}
Config cfg{ .host = "prod.example.com", .port = 443 };
dump_fields(cfg);
// host: prod.example.com
// port: 443
// timeout: 30
// verbose: falseAs of 2026, P2996 is in active wording review for C++26 and is implemented experimentally in EDG and the Clang-based p2996 branch; it is not yet available in stable GCC or MSVC releases. The template for syntax and ^^ reflection operator are the current paper syntax; minor changes before final standardization remain possible.
See Also
- Aggregate Initialization — the mechanism designated initializers extend
- CTAD — class template argument deduction, commonly used with options aggregates
- Default Member Initializers — C++11 syntax that pairs naturally with partial designated init