· 5 min read ·

C++26 Reflection Makes Strong Typedefs a Compile-Time Problem

Source: isocpp

C++ has always had a typedef problem. using UserId = int does not create a new type. It creates an alias. The compiler sees them as identical, so passing a ProductId where a UserId is expected compiles without complaint. The names exist for human readers; the type system ignores them.

The standard workaround is a wrapper class: a struct with a private value and a pile of forwarded operators. That works well for scalar types like integers. You write it once, template it with a phantom tag type, and you get Jonathan Boccara’s NamedType or something close to it. The interface is clean at the use site:

using UserId   = NamedType<int, struct UserIdTag,   Addable, Comparable>;
using OrderId  = NamedType<int, struct OrderIdTag,  Addable, Comparable>;

But this breaks down the moment your underlying type has a rich interface. If Item exposes name(), price(), discount(), category(), and a constructor taking five arguments, every strong wrapper around Item needs to manually delegate each of those. Either you write the forwarding by hand, or you reach for a macro. Neither is satisfying.

The r/cpp thread linked from isocpp.org demonstrates something more interesting: using C++26 static reflection to generate that wrapper automatically, at compile time, with the full interface forwarded.

What P2996 Adds to the Language

P2996 is the static reflection proposal that was voted into the C++26 working draft in early 2025. Its central contribution is compile-time introspection of program structure. The ^ operator (written as ^^ in the EDG experimental compiler) produces a std::meta::info value representing a type, function, data member, or enumerator. That value can be passed to consteval functions and queried through the std::meta API.

The most relevant pieces for strong typedefs are:

  • std::meta::nonstatic_data_members_of(r): returns a range of std::meta::info values for every non-static data member of a type
  • std::meta::member_functions_of(r): same for member functions
  • std::meta::define_class(tag, member_specs): synthesizes a new struct type at compile time given a list of data member specifications
  • std::meta::data_member_spec(type, options): constructs the spec for one member
  • The [: :] splice operator: turns a std::meta::info value back into a name, type, or expression in context

There are also consteval { } blocks, which let you write top-level compile-time code without wrapping it in a named consteval function:

struct FoodItem;
struct BookItem;
struct MovieItem;

consteval {
    make_strong_typedef(^^FoodItem, ^^Item);
    make_strong_typedef(^^BookItem, ^^Item);
    make_strong_typedef(^^MovieItem, ^^Item);
}

At the end of that block, FoodItem, BookItem, and MovieItem are fully defined types with the complete interface of Item, but they are nominally distinct. You cannot pass a BookItem where a FoodItem is expected, even though both have identical layouts.

The Two-Stage Reality

The thread author notes one important caveat: they are using queue_injection, an EDG extension that was not standardized into C++26. This mechanism lets you inject member declarations into a type that is still being defined. It is the feature that makes single-pass strong typedef generation work cleanly.

The committee deferred queue_injection (formalized in P3294) because the interaction with C++‘s rules around incomplete types, ODR, and template instantiation ordering is genuinely difficult. When you inject a member function into a type mid-compilation, the compiler needs to decide whether declarations in flight still see the incomplete version or the injected one. That touches deep parts of the compilation model.

Without queue_injection, you need two build stages: the first pass uses reflection to emit generated header files, and the second compilation includes those headers. This is the same pattern as protoc or flatc, just driven from C++ itself rather than an external tool. It works, but it adds build infrastructure. The seamless single-pass experience described in the thread depends on the EDG extension.

P3294 is currently targeting C++29. Until then, the two-stage approach is the portable path for full member function forwarding.

What C++26 Can Do Today

Data member layout copying is fully supported in C++26 via define_class. You can enumerate every field of a source type and synthesize a new struct with identical layout:

consteval std::meta::info clone_layout(std::meta::info source, std::meta::info tag) {
    std::vector<std::meta::info> specs;
    for (auto member : std::meta::nonstatic_data_members_of(source)) {
        specs.push_back(std::meta::data_member_spec(
            std::meta::type_of(member),
            { .name = std::meta::identifier_of(member) }
        ));
    }
    return std::meta::define_class(tag, specs);
}

The resulting type has the same fields, same names, same types, but it is a different type. This alone handles many strong typedef use cases where the “interface” is just direct field access.

For member function forwarding in C++26 today, the approach is to write the delegation manually or generate the header file externally. std::meta::member_functions_of can enumerate the functions you need to forward, and name_of, type_of, and parameters_of give you enough to construct signatures. It is tedious but mechanically straightforward.

How Rust Settled This Differently

Rust made the newtype pattern a first-class part of the language from the beginning. A tuple struct with a single field is a new type:

struct FoodItem(Item);
struct BookItem(Item);

impl FoodItem {
    fn name(&self) -> &str { self.0.name() }
    fn price(&self) -> f64 { self.0.price() }
}

You still write the forwarding, but the pattern is canonical and expected. The newtype_derive crate automates it with macros. The difference is cultural: Rust treats newtypes as a normal thing to do frequently, so the tooling supports it. C++ treated the problem as a footnote, which is why libraries like NamedType existed for years before anything better came along.

C++26 reflection inverts the economics. Generating a full strong typedef wrapper is now a library problem rather than a language problem. Someone writes make_strong_typedef once (correctly), publishes it, and every C++26 codebase can use it. You do not need CRTP, you do not need macros, and you do not need to manually forward thirty methods. The wrapper is generated from the type itself.

Where This Fits in the Bigger Picture

The strong typedef use case is one of several that the reflection community has been demonstrating since P2996 landed. Compile-time struct serialization, automatic operator== generation, enum-to-string tables, and property systems are all in the same family: tasks that previously required macros or external code generators, now expressible as consteval library code.

The pattern that emerges is that C++26 reflection is primarily a tool for library authors. End users write concise declarative code; the complexity lives in the consteval implementation of make_strong_typedef or its equivalent. This mirrors how Rust derive macros work: complex procedural macro code, simple use site.

The thread on r/cpp is a proof of concept, and the author is honest about the limitations. Aggregate initialization does not fully work yet. Queue injection is experimental. Some corner cases in the interface wrapping are incomplete. These are prototype limitations, not fundamental design flaws. The underlying machinery in P2996 is sound, and the experimental compilers are close enough to the final standard that the approach is clearly viable.

The Bloomberg Clang fork tracking P2996 and the EDG implementation are both available on Compiler Explorer if you want to experiment today. The feature set that made it into C++26 is stable enough to build on, even if the most ergonomic version of strong typedef generation is still waiting for C++29.

Was this interesting?