C++20 Designated Initializers: Named Struct Fields, Order Rules and Nested Configs

Key takeaways

How C++20 designated initializers work on aggregates, why designators must follow declaration order, what C99 features C++ rejects, the exact compiler errors, and how to use them for config structs and optional parameters.

Why designated initializers exist

Positional aggregate initialization ties every call site to the order of the members. With five fields of similar types it becomes impossible to review:

struct Config {
    std::string host;
    int port;
    bool ssl;
    int timeout;
    int max_connections;
};

Config cfg = {"localhost", 8080, true, 30, 100};  // which int is the timeout?

C++20 lets you name the members:

Config cfg = {
    .host = "localhost",
    .port = 8080,
    .ssl = true,
    .timeout = 30,
    .max_connections = 100,   // a trailing comma is allowed
};

The readability gain is obvious, but the bigger win is maintenance. The failure I have seen most often with positional init is someone inserting a new int member in the middle of a struct: every existing {...} literal still compiles and silently shifts values into the wrong fields. With designators, the same change either keeps working (the new member gets its default) or fails loudly if a member was renamed or reordered. That compile error is the feature.

There is no runtime cost. A designated initializer is still aggregate initialization; the compiler just checks names at compile time.


The rules in one place

Designated initializers are a restricted subset of the C99 feature:

FeatureC99/C11C++20
{.a = 1, .b = 2}YesYes
Skip members ({.a = 1, .c = 3})YesYes
Out-of-order designatorsYesNo (ill-formed)
Mix positional and designatedYesNo
Nested designator .in.k = 1YesNo (write .in = {.k = 1})
Array designator [2] = 5YesNo
Narrowing (.a = 1.5 into int)AllowedError, as with any list-init

Other requirements:

  • The type must be an aggregate: no user-declared constructors (C++20 tightened this, so even S() = default; disqualifies), no private or protected non-static data members, no virtual functions, no virtual/private/protected bases.
  • A designator must name a direct non-static data member. You cannot designate a member that lives in a base class.
  • For a union, you designate exactly one member.
  • Members you omit are initialized from their default member initializer, or else from {} (zero for scalars, default-constructed for class types).
struct Data { int a; int b; int c; };

Data d1 = {.a = 1, .b = 2, .c = 3};  // OK
Data d2 = {.a = 1, .c = 3};          // OK, b == 0
Data d3{.c = 3};                     // OK, direct-list-initialization form
// Data d4 = {.b = 2, .a = 1};       // error: order
// Data d5 = {1, .b = 2};            // error: mixing

Compiler errors you will actually see

All GCC messages below were reproduced with GCC 10.3 and -std=c++20.

Out of order

Data d = {.b = 2, .a = 1};
error: designator order for field 'Data::a' does not match declaration order in 'Data'

Clang is more lenient here: it accepts out-of-order designators as an extension and emits a -Wreorder-init-list warning. This is a real portability trap. Code written on macOS with Clang can build fine and then fail in a GCC-based Linux CI. If you develop on Clang, build with -Werror=reorder-init-list so you get the same result.

Mixing positional and designated

Data d = {1, .b = 2};
error: either all initializer clauses should be designated or none of them should be

Clang again treats this as a C99 extension with a warning (-Wc99-designator) rather than a hard error.

Typo or member that does not exist

Data d = {.a = 1, .zz = 2};
error: 'Data' has no non-static data member named 'zz'

Base-class member

struct Base { int x; };
struct Derived : Base { int y; };
Derived dd = {.x = 1, .y = 2};
error: 'Derived' has no non-static data member named 'x'

Derived is still an aggregate (C++17 allows public bases), so Derived{.y = 2} compiles and value-initializes the Base subobject. You just can’t name base members. If you need them, either use positional init (Derived{{1}, 2}) or replace the base with a named member (Base base; int y; → {.base = {.x = 1}, .y = 2}).

User-declared constructor

struct Def { Def() = default; int v; };
Def df = {.v = 1};
error: could not convert '{1}' from '<brace-enclosed initializer list>' to 'Def'

This one surprises people upgrading from C++17, where a defaulted constructor still left the type an aggregate. In C++20 it doesn’t. Delete the constructor and use default member initializers instead.

A related gotcha: GCC 10.3 accepted Ctor c = {.value = 10}; for a class with a Ctor(int) constructor. It ignored the designator and called the constructor, and didn’t warn even with -pedantic. Clang rejects that code. Don’t rely on the GCC behavior.

Narrowing

Data d = {.a = 1.5};
error: narrowing conversion of '1.5e+0' from 'double' to 'int' [-Wnarrowing]

Nested designator (C syntax)

struct N { struct In { int k; } in; int z; };
N n = {.in.k = 1};   // error: expected primary-expression before '.' token
N ok = {.in = {.k = 1}};

Defaults and partial initialization

Designated initializers pair best with default member initializers. Callers state only what differs:

struct Settings {
    int width = 800;
    int height = 600;
    bool fullscreen = false;
    int fps = 60;
};

Settings s1 = {.width = 1920, .height = 1080};  // fullscreen=false, fps=60
Settings s2 = {.fullscreen = true};             // 800x600, 60 fps
Settings s3 = {};                               // all defaults

Members without a default are zero-initialized when you skip them. That’s safe, but it can hide a required field that someone forgot. -Wextra in GCC 10.3 enables -Wmissing-field-initializers, which fires for designated initializers too:

warning: missing initializer for member 'P::b' [-Wmissing-field-initializers]

It did not fire for members that have a default member initializer. So adding defaults to optional fields turns the warning into a useful “you forgot a required field” check. For fields that must never be missing, validate in the function that consumes the struct. The type system won’t do it for you.


Nested structs

Each nested aggregate gets its own braced, designated list:

struct Location { double lat; double lon; };
struct Address  { std::string street; Location location; };
struct Person   { std::string name; Address address; };

Person p = {
    .name = "Bob",
    .address = {
        .street = "Main St",
        .location = {.lat = 37.5, .lon = 127.0},
    },
};

The order rule applies at each level independently.


Pattern: named optional parameters

C++ has no keyword arguments, but an options struct plus designated initializers gets close:

struct RenderOptions {
    int width = 800;
    int height = 600;
    bool antialiasing = true;
    int samples = 4;
    std::string output_format = "png";
};

void render(const std::string& scene, const RenderOptions& o = {});

render("scene1.obj");
render("scene2.obj", {.width = 1920, .height = 1080});
render("scene3.obj", {.width = 3840, .height = 2160, .samples = 8, .output_format = "exr"});

This replaces most hand-written builders for plain configuration. A builder still earns its place when construction needs validation or invariants that must hold at all times. An aggregate has public members, so anything can be assigned later.

One trap with this pattern: overload resolution looks at designator names. If you have void f(A) and void f(B), and both structs have a member x, then f({.x = 1}) is ambiguous:

error: call of overloaded 'f(<brace-enclosed initializer list>)' is ambiguous

f({.y = 1}) works if only B has y. Relying on that is fragile. Give each overload a distinct name, or spell out the type: f(B{.x = 1}).


Complete example: HTTP request configuration

Compiled and run with GCC 10.3, -std=c++20 -Wall:

#include <chrono>
#include <iostream>
#include <string>

struct HttpHeaders {
    std::string content_type = "application/json";
    std::string authorization;
    std::string user_agent = "MyApp/1.0";
};

struct HttpRequest {
    std::string method = "GET";
    std::string url;
    HttpHeaders headers;
    std::string body;
    std::chrono::seconds timeout{30};
    bool follow_redirects = true;
    int max_redirects = 5;
};

void send_request(const HttpRequest& req) {
    std::cout << req.method << ' ' << req.url
              << " auth=" << req.headers.authorization
              << " timeout=" << req.timeout.count() << "s\n";
}

int main() {
    send_request({.url = "https://api.example.com/users"});

    send_request({
        .method = "POST",
        .url = "https://api.example.com/users",
        .headers = {.authorization = "Bearer token123"},
        .body = R"({"name":"Alice"})",
        .timeout = std::chrono::seconds(60),
    });
}

Output:

GET https://api.example.com/users auth= timeout=30s
POST https://api.example.com/users auth=Bearer token123 timeout=60s

In the POST request, .headers = {.authorization = ...} keeps the other two header defaults (content_type, user_agent). The nested list is its own designated initializer, so omitted members fall back to their default member initializers. A misconception I’ve run into more than once is that .headers = {} resets the headers to something other than what you get by omitting .headers. It doesn’t. Both use the default member initializers of HttpHeaders. The same goes for send_request({...}) itself: the braced list builds a temporary HttpRequest bound to the const& parameter, so you don’t need a named variable.


When to use them

  • Use for config and options structs, test tables ({.name = "zero", .input = 0, .expected = 0}), and any aggregate with several members of the same type.
  • Skip for two-member types like Point{1, 2}, where positional init is clearer.
  • Don’t force it on classes with invariants. Keep the constructor and give it well-named parameters or strong types.
  • Treat member names of widely used structs as public API. Renaming a field now breaks every designated call site at compile time. That’s usually what you want, but it matters for library authors.

Compiler support: cppreference lists GCC 8, Clang 10 and MSVC 19.21 (Visual Studio 2019 16.1) for the C++20 form. MSVC needs /std:c++20 or /std:c++latest. GCC and Clang also accept designators in older -std modes as an extension. GCC 10.3 warns about this with -pedantic (“C++ designated initializers only available with ‘-std=c++2a’”), and Clang warns via -Wc++20-designator. If your code base still builds as C++17 anywhere, turn those warnings into errors.