Skip to content
Dev Tools Article

C++26 Reflection Finally Fixes Type Erasure

Trait declarations generate vtables at compile time, ending the Concept/Model boilerplate tax.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jul 14, 2026 · 7 min read
C++26 Reflection Finally Fixes Type Erasure

Type erasure is the C++ trick that lets you hold a std::vector, a std::string, and a std::map behind one interface and call size() on whichever is live. Everyone uses the polished cases (std::function, std::any, std::function_ref in C++26). Almost nobody rolls their own for a richer interface, because the cost is brutal.

You either write a virtual Concept base plus a templated Model that forwards every method, or you reach for a macro-heavy library. C++26 reflection changes the economics. A library like rjk::duck shows the new shape: declare a trait once, annotate it, and the compiler builds the vtable machinery. That is a real shift for library authors, not a toy demo, even if the toolchain story is still experimental.

Why the old patterns hurt

Classic object-oriented type erasure needs an abstract base and inheritance. That fails the moment you want to erase types you do not own (std::string, third-party classes, built-ins). The template-based pattern fixes ownership of the concrete type by hiding it behind a type-erased shell:

class Object {
  struct Concept {
    virtual ~Concept() {}
    virtual std::string getName() const = 0;
  };
  template<typename T>
  struct Model : Concept {
    Model(T t) : object(std::move(t)) {}
    std::string getName() const override { return object.getName(); }
    T object;
  };
  std::shared_ptr<const Concept> object;
public:
  template<typename T>
  Object(T&& obj)
    : object(std::make_shared<Model<std::decay_t<T>>>(std::forward<T>(obj))) {}
  std::string getName() const { return object->getName(); }
};

That works. It is also tedious, easy to get wrong on special members, and scales poorly once you want operators, const overloads, multiple interfaces, or non-owning views. Libraries such as Boost.TypeErasure and Folly.Poly exist because people got tired of retyping this. They trade one kind of boilerplate for another (macros, concept maps, registration tables).

std::function and friends only look simple because the standard library authors absorbed that pain for one narrow interface shape. A general trait with a dozen methods is still a multi-day exercise.

Reflection as code generation that is still C++

C++26 reflection (see the P2996 design) gives you a compile-time handle on types and members via ^^ and std::meta::info, plus a limited code-generation primitive: define_aggregate inside a consteval block. That is enough to turn a declarative trait into a vtable.

The duck approach starts with an annotation, not a base class:

struct [[=rjk::trait]] Container {
  auto size() const -> std::size_t;
  auto empty() const -> bool;
  auto clear() -> void;
};

rjk::duck<Container> c{std::vector<int>{1, 2, 3}};
c.size();                 // 3
c = std::string{"hello"}; // swap concrete type at runtime
c.size();                 // 5

trait itself is just an empty constexpr tag object. Reflection inspects annotations_of on the type, walks public function members, and builds internal tags of the form has_fn<"size", auto() const -> std::size_t>. A consteval expansion over those tags then emits function-pointer slots on a generated vtable struct (plus the usual destroy/copy/move/typeid machinery). Overloads get renumbered slots so two members can share a name; names are re-derived at the call site.

The important property is that your existing free or member functions already satisfy the trait. No inheritance, no manual Model specialization, no macro registration. The library is a single header and also covers non-owning views, operators, trait composition, adapters for older interfaces, and extension methods on third-party types.

What library authors should actually do with this

If you ship a C++ library that today forces users into either templates everywhere or a hand-rolled virtual hierarchy, this pattern is the escape hatch you have been waiting for.

Concrete adoption path once more compilers catch up:

  1. Sketch the interface as a plain struct of function declarations. Mark it with the trait annotation.
  2. Expose duck<YourTrait> (or your own thin wrapper) in the public API instead of a virtual base or a template parameter soup.
  3. Prefer non-owning duck variants at API boundaries when the caller already owns the object; use owning storage for containers of mixed types.
  4. Compose small traits rather than one god-interface. Reflection can walk bases, so composition stays cheap to express.

What it replaces: ad-hoc Concept/Model pairs inside your codebase, and much of the reason people pull in Boost.TypeErasure for modest interfaces. What it does not replace: std::function / std::move_only_function / std::function_ref for the single-callable case (those are already standardized and tuned), or value-semantic polymorphism libraries that optimize layout aggressively (dyno, te, proxy) when you need every cache line.

Trade-offs to weigh before you commit an API:

  • Tooling. Current support is gcc with -std=c++26 -freflection. This is bleeding-edge. Do not ship a public header that requires it unless you also ship a fallback or gate the feature hard.
  • Overloads and operators. Name collisions on the vtable force indirection through slots. That is solvable, but it is more machinery than the happy path of unique method names.
  • Diagnostics. When a concrete type fails to model the trait, you want the error at the construction site, not deep inside generated code. Expect to invest in constraints and static asserts on top of the reflection walk.
  • Binary size and inlining. You still pay an indirect call per method, same as classic type erasure. The win is authoring cost and correctness, not magical zero-overhead abstraction.

If you maintain an internal polymorphic interface used by a few teams, experimenting now is reasonable: the reflection surface is stable enough in the paper to learn, and the pattern teaches you where your real interface boundaries are. If you maintain a widely consumed open-source library, wait for a second compiler and for the reflection APIs to stop moving.

The interconvertibility angle and why size stays small

One subtlety that keeps the design practical is that distinct trait combinations do not each need a wholly separate erased type graph. Reflection-driven tags and a shared generation strategy let related ducks interconvert without exploding the number of vtable shapes. That matters when you compose interfaces: duck<Drawable, Serializable> should not become a combinatorial nightmare of handwritten wrappers.

This is the same design pressure that made std::function painful to extend and that pushed libraries toward concept maps and external polymorphism. Generating the slots from the trait definitions instead of writing them by hand is what makes composition feel like normal C++ again.

Verdict

Type erasure has always been the right tool when you need runtime diversity without infecting every caller with templates. The barrier was never the idea; it was the ceremony. C++26 reflection removes most of that ceremony by turning a declarative trait into tags and a vtable inside a consteval block.

Is it production-ready today? No. One compiler flag on one compiler is a research platform, not a portability story. Is it a genuine shift? Yes. Once reflection lands broadly, hand-written Concept/Model pairs for routine interfaces will look as dated as pre-std::function function-pointer tables. Library authors who care about clean polymorphic APIs should learn the pattern now, prototype behind feature macros, and plan the migration for the C++26 toolchain wave rather than inventing yet another macro DSL.

Sources & further reading

  1. Beautiful Type Erasure with C++26 Reflection — ryanjk5.github.io
  2. Type Erasure – MC++ BLOG — modernescpp.com
  3. C++ 'Type Erasure' Explained | Dave Kilian's Blog — davekilian.com
  4. type-erasure · GitHub Topics · GitHub — github.com
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading