Every C++ developer who has built a data-mapping layer knows the problem: you define a struct, write the code that serializes it, write the code that deserializes it, and then add a field months later and forget to update one of those three places. The compiler does not help you. Tests might catch it. Runtime errors definitely will.
This is the mundane problem that C++26 static reflection (proposal P2996) is quietly built to solve, even though most of the attention goes to the more spectacular uses. A recent post by GitHub user friedkeenan walks through a translation system as a grounding example, and it illustrates the practical shape of reflection well.
The Problem in Concrete Form
Consider a simple localization setup. You have a struct of strings representing every key in your UI:
struct Translations {
std::string welcome;
std::string goodbye;
std::string error_not_found;
};
Now you load these from a JSON file, keyed by the field names. Without reflection, you write something like:
Translations load(const nlohmann::json& j) {
Translations t;
t.welcome = j["welcome"];
t.goodbye = j["goodbye"];
t.error_not_found = j["error_not_found"];
return t;
}
This is three places that must stay in sync: the struct definition, the loader, and the JSON file itself. Every new key means touching all three. The mapping between field name and JSON key is completely implicit, maintained only by programmer discipline.
Before C++26, the standard workarounds were all painful. X-macros let you define fields once and expand them multiple times:
#define TRANSLATION_FIELDS \
X(welcome) \
X(goodbye) \
X(error_not_found)
struct Translations {
#define X(name) std::string name;
TRANSLATION_FIELDS
#undef X
};
This works, but it is macro programming with all the attendant debuggability problems. External code generators like Protobuf solve a related problem but require you to leave C++ and define your schema in a separate DSL. Boost.Hana can do struct member introspection, but requires every struct to opt in with a specific macro at definition time. None of these feel like the language is helping you.
What C++26 Reflection Actually Gives You
P2996 adds three things to the language. The reflection operator ^ produces a std::meta::info value representing any entity: a type, a variable, a function, a namespace. Metafunctions in <experimental/meta> operate on these values at compile time, giving you names, types, and membership information. Splicers written as [:r:] turn a std::meta::info value back into actual source code at the splice point.
The combination lets you write a generic loader:
#include <experimental/meta>
template <typename T>
T load_from_json(const nlohmann::json& j) {
T result;
[:expand(std::meta::nonstatic_data_members_of(^T)):] >> [&]<auto mem> {
auto key = std::string(std::meta::identifier_of(mem));
result.[:mem:] = j.at(key).get<std::string>();
};
return result;
}
The expand construct iterates over a pack of reflections. std::meta::nonstatic_data_members_of(^T) returns a compile-time sequence of std::meta::info values, one per field. std::meta::identifier_of(mem) gives you the field name as a std::string_view. The splice result.[:mem:] accesses the field itself, as if you had written result.welcome by hand.
Now adding error_context to Translations requires exactly one change: the struct definition. The loader handles it automatically.
Comparing Other Languages
The interesting framing here is not that reflection is new, but that C++ has been one of the last mainstream systems languages without it.
Rust does not have runtime reflection, but its derive macro system provides a compile-time analog. You annotate a struct with #[derive(Deserialize)] and the serde crate generates all the mapping code during compilation. The difference is that serde ships with Rust’s ecosystem and does not require language-level access to member metadata; it hooks into the compiler’s syntax tree during macro expansion. C++26 reflection is closer to giving you the capability to write your own serde without needing compiler plugin access.
Go uses struct tags embedded in field declarations:
type Translations struct {
Welcome string `json:"welcome"`
Goodbye string `json:"goodbye"`
ErrorNotFound string `json:"error_not_found"`
}
The standard library’s encoding/json package reads these tags at runtime via reflect.TypeOf. It is ergonomic, but the reflection is dynamic and carries runtime overhead. C++26 reflection is entirely static; everything happens at compile time, with no runtime cost for the introspection itself.
Java’s annotation-based reflection is the historical reference point for runtime reflection at scale, but it has well-known overhead problems and is the reason libraries like MapStruct exist to generate code at build time instead. C++ takes the compile-time approach by design, which fits its zero-overhead philosophy.
Where the Trade-offs Live
Friedkeenan’s post is careful to note that not every option presented is obviously the right one. Reflection introduces complexity of its own. The expand and splice syntax is unfamiliar. Error messages from template metaprogramming are already notoriously difficult to read; reflection-heavy code will not improve that situation immediately, though compilers will get better over time.
The sweet spot for everyday reflection is exactly the case shown here: code where you already have a struct that is the source of truth, and you need other code to stay in sync with it automatically. Serialization, deserialization, ORM field mapping, config struct loading, and translation key management all fit this shape. The struct stays readable and debuggable; the generic infrastructure handles the mechanical mapping.
Where reflection starts to cost more than it saves is when the struct is not the right primitive for the problem. If your translation keys are dynamically discovered at runtime, or if you need fallback chains and plural forms, a plain struct stops being the right model. You end up using reflection to paper over a design problem rather than to simplify a correct one.
Friedkeenan explores a third option: using reflection to synthesize the struct entirely from a list of string keys, so there is no manually maintained struct at all. This is where the technique becomes harder to justify for ordinary code. Generated types from string literals work, but they complicate debugging, IDE support, and anything that tries to reference a field by name in non-reflective code. The ergonomic ceiling for that approach is lower than it appears.
Compiler Support Today
C++26 reflection is available experimentally in GCC 15 with -std=c++26 -freflection and in Clang 20 with -std=c++26 -freflection -freflection-new-syntax. The header is <experimental/meta>. The syntax has seen revisions between drafts, so code written against earlier EDG or Clang prototypes may need updating against the current P2996R10 wording.
The standard is not yet finalized. The SG7 subgroup was still working through details as recently as May 2025. Shipping code that depends on reflection today means accepting that the exact syntax may shift before the standard locks down, though the semantics are fairly stable at this point.
The Broader Shift
What the translation system example demonstrates is less about translation and more about a category of C++ code that has always required either discipline or external tooling. Struct-to-map synchronization, field enumeration, generic serialization: these are problems every sizable C++ codebase faces, and the solutions have always required going outside the language itself.
Reflection brings that capability in-language. You can write generic utilities that operate on struct fields without macros, without code generators, and without requiring your structs to inherit from a special base class or be annotated with opt-in boilerplate. The utility code is more complex, but it is written once, and the structs that use it require no changes at all.
That is the mundane promise of C++26 reflection: not synthesizing types from nothing or doing extraordinary compile-time computation, but just letting you write the infrastructure code once and trust that it will keep working when your data structures change.