Skip to content
C++
Language
since C++20
Basic

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++20

A 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

cpp
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 host

Undesignated 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.

cpp
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:

cpp
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:

cpp
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:

cpp
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 designator

Aggregates 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:

cpp
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.

cpp
// 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):

cpp
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 warning

Always 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:

cpp
struct Rect { int x, y, w, h; };

Rect r{ .w = 640, .h = 480 };
// x == 0, y == 0 — value-initialized, not uninitialized

Non-aggregate types silently rejected

Any user-provided constructor (as opposed to = default or = delete) disqualifies the type:

cpp
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 aggregate

The 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

FeatureC99C++20
Out-of-order designatorsAllowedIll-formed
Array subscript [N] =SupportedNot supported
Chained .a.b =SupportedNot supported — use .a = { .b = ... }
Mix designated and positionalAllowedIll-formed
Multiple union designatorsAllowed (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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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++20

The 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.

cpp
// 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.

cpp
// 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-formed

The 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:

cpp
// 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.

cpp
// 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.

cpp
// 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.

cpp
// 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 == true

This 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.

cpp
// 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: false

As 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