← Back to list

Static Reflection in C++26 (Part 5): Building a Reflection-Powered Formatter

Unlocking std::meta::bases_of and the unchecked access context to build a macro-free Generic Formatter.

Sagar in Towards Dev · 2026-07-09 11:11 · 12 claps · 4.7 min read
#programming #technology #software-development #cpp26 #c-plus-plus-language
Open on Medium ↗
Wiki topics: 💻 · Programming

Static Reflection in C++26 (Part 5): Building a Reflection-Powered Formatter

Unlocking std::meta::bases_of and the unchecked access context to build a macro-free Generic Formatter.

Want to play with reflection? Since major compilers (GCC, MSVC, mainline Clang) haven’t added reflection support yet, I’ve created **ReflectionBox** to make testing easy. Just clone the repo, run ./reflect your_file.cc, and see the output! It pulls a pre-built Docker image with Bloomberg’s P2996 Clang fork. All you need is Docker—no local LLVM build required.

Welcome to Part 5 of our C++26 static reflection series!

If you have been following along, we have covered a massive amount of ground:

  • **Part 1: The `^^** and[: :]` operators.
  • **Part 2: Introspection with `<meta>`**.
  • **Part 3: Type synthesis via `define_aggregate`**.
  • **Part 4**: Addressed splicing and runtime execution.

Today, we are going to combine all of these concepts to solve a problem that every C++ developer faces daily.

Every C++ developer has written an **operator<< or a `std::formatter`** specialization at least once. Then someone adds a new member, forgets to update the formatter, and suddenly your logs lie.

Static reflection eliminates that entire class of bugs.

Today, we are going to write a Generic Reflection Formatter: a single, macro-free struct that automatically prints any opted-in C++ object, including all of its base classes and data members.

The Mental Model

To format a complex C++ object, we need to iterate over its structure at compile time.

If we have a class **Z that inherits from `X** andY`, our formatter needs to:

  1. Print the name of the class (**Z**).
  2. Discover and print the base classes (**X and `Y`**).
  3. Discover and print its own data members.

Here is what our reflection pipeline will look like:

Extracting Base Classes

We already know how to get data members using **std::meta::nonstatic_data_members_of**. But how do we handle inheritance?

The standard provides **std::meta::bases_of(^^T, ctx), which returns a vector of reflections representing the direct base classes of `T`**.

To format a base class, we need to cast our derived object (**Z) to a reference of its base class (`X`**), so we can print it. We can do this dynamically using type splicing:

constexpr auto bases = std::meta::bases_of(^^Z, ctx);
constexpr auto base_refl = bases[0]; // Reflection of X

// We get the type of the base class using type_of
constexpr auto base_type = std::meta::type_of(base_refl);

// Now we use [: :] to splice the base type directly into a static_cast!
auto const& base_obj = static_cast<[: base_type :] const&>(my_z_obj);

This is a beautiful demonstration of how seamlessly reflection integrates with core C++ syntax. **[: base_type :] acts exactly as if you had typed `X`**.

The Unchecked Access Context

In Part 2, we learned about **std::meta::access_context::current()**, which only returns the members that are publicly visible from the current scope.

But when we are writing a serialization or formatting library, we often want to inspect everything — including **private and `protected** fields. To achieve this, the<meta>` header provides a powerful override:

// Bypasses all C++ visibility rules!
constexpr auto ctx = std::meta::access_context::unchecked();

By using the **unchecked()** context, our formatter will be able to discover and print the deeply hidden private state of any object.

Like any powerful tool, this should generally be reserved for infrastructure libraries such as serializers, debuggers, and formatting frameworks rather than everyday application code.

Putting it together: The Code

Let’s write the actual formatter. We will hook into the standard C++20 **<format> library by creating a base struct called `universal_formatter`**.

Note: Just like in Part 4, we will use the **template for** expansion statement (P1306) to unroll our reflection loops at compile time.

#include <meta>
#include <format>
#include <iostream>
#include <string_view>

struct universal_formatter {
    // 1. Tell std::format how to parse the format string (we just accept the default)
    constexpr auto parse(auto& ctx) { return ctx.begin(); }

    // 2. The actual formatting logic
    template <typename T>
    auto format(T const& t, auto& ctx) const {

        // Output the name of the struct (e.g., "Z{")
        std::string_view type_name = std::meta::has_identifier(^^T) 
                                     ? std::meta::identifier_of(^^T) 
                                     : "(unnamed-type)";
        auto out = std::format_to(ctx.out(), "{}{{", type_name);

        bool first = true;
        auto delim = [&]() mutable {
            if (!first) out = std::format_to(out, ", ");
            first = false;
        };

        // We want to see EVERYTHING, including private members.
        constexpr auto access = std::meta::access_context::unchecked();

        // 3. Iterate over the Base Classes
        template for (constexpr auto base : std::meta::bases_of(^^T, access)) {
            delim();
            // Dynamically cast 't' to its base class type and format it!
            out = std::format_to(out, "{}", static_cast<[: std::meta::type_of(base) :] const&>(t));
        }

        // 4. Iterate over the Data Members
        template for (constexpr auto mem : std::meta::nonstatic_data_members_of(^^T, access)) {
            delim();
            std::string_view mem_name = std::meta::has_identifier(mem) 
                                        ? std::meta::identifier_of(mem) 
                                        : "(unnamed-member)";

            // Splice the member to get its runtime value!
            out = std::format_to(out, ".{}={}", mem_name, t.[:mem:]);
        }

        // Close the struct
        return std::format_to(out, "}}");
    }
};

Because every base class we pass to **std::format_to also opts into using `universal_formatter`** (as we will see below), this base-class formatting naturally becomes recursive!

Now that our engine is built, how do we use it?

Any time we create a new struct, all we have to do is declare that its **std::formatter inherits from our `universal_formatter**. We never have to write a customoperator<<` again.

struct Base_A { int m0 = 0; };
struct Base_B { int m1 = 1; };

class Derived : public Base_A, private Base_B { 
    int m2 = 2; 
    int m3 = 3; 
};

// Opt-in to the universal formatter (Zero boilerplate!)
template <> struct std::formatter<Base_A>  : universal_formatter { };
template <> struct std::formatter<Base_B>  : universal_formatter { };
template <> struct std::formatter<Derived> : universal_formatter { };

int main() {
    Derived obj;

    // std::println automatically invokes our reflection formatter!
    std::println("{}", obj);

    // Output:
    // Derived{Base_A{.m0=0}, Base_B{.m1=1}, .m2=2, .m3=3}
}

Think about how much time this saves. In massive codebases, keeping debug printers and JSON serializers in sync with header files is an endless chore.

The compiler already knows the complete structure of every type in your program. C++26 finally gives us a standard way to use that knowledge instead of rewriting it by hand.

Wrapping Up Part 5

Today, we took another massive leap. We combined type splicing, base-class introspection (**std::meta::bases_of), and the `unchecked()`** access context to solve the ultimate boilerplate problem: string formatting.

We now have the power to seamlessly inspect complex inheritance hierarchies and safely bypass visibility constraints for serialization frameworks.

What’s next?

In Part 6, we are going to use reflection to take down a legendary C++ compile-time monster: **std::tuple and `std::variant`. We will use our reflection toolkit to construct a completely flat, blazingly fast Tuple, solve the destructive union problem to build a Variant, and even show how easy it is to implement a **Named Tuple without recursive inheritance!

Found this article helpful? Please Clap 👏 and follow for more C++ and system programming content.


메타데이터
post_id
d68a190eecfa
slug
cpp26-static-reflection-std-formatter-generation-d68a190eecfa
url
https://towardsdev.com/cpp26-static-reflection-std-formatter-generation-d68a190eecfa
canonical_url
https://towardsdev.com/cpp26-static-reflection-std-formatter-generation-d68a190eecfa
author_url
https://medium.com/@sagarmadala
status
ok
fetched_at
2026-07-15 13:22:43