Class Types as NTTP in Modern C++

Modern C++ significantly expanded non-type template parameters (NTTP) by allowing literal class types to be passed directly as template arguments. This capability is made possible through the standard's formal introduction of "structural types," a classification that ensures user-defined objects can be evaluated, compared, and uniquely identified at compile time. This article explores the mechanics of structural type qualification, the specific language criteria a class must fulfill, and how compilers leverage these rules to enable compile-time constructs like string literals and fixed-size aggregates as template parameters.

Prior to C++20, non-type template parameters were largely restricted to scalar types, including integers, enumerations, pointers, and lvalue references. Floating-point types and user-defined class types were prohibited primarily due to the template identity problem: the compiler and linker must deterministically determine whether two template specializations, such as Template<objA> and Template<objB>, refer to the exact same type across different translation units. Without a language-enforced mechanism to evaluate identity based on object state, classes could not safely serve as compile-time keys.

C++20 resolved this by defining a structural type. An object type is structural if it falls into one of the following categories:

  • A scalar type (integers, floats, pointers, enums).
  • An lvalue reference type.
  • A literal class type where:
    1. All non-static data members and base classes are public and non-mutable.
    2. The types of all base classes and non-static data members are themselves structural types (or arrays thereof).

The requirement for public, non-mutable members ensures that the entire state of the object is transparent and visible to the compiler. Because the data layout is open and composed entirely of types that also have deterministic value representations, the compiler can inspect the object recursively down to its constituent scalar members.

Structural type qualification enables class NTTPs by changing how equivalence is defined. When a class type qualifies as structural, two values A and B passed as template arguments are considered to represent the identical template specialization if and only if:

  • They are the same type.
  • For each corresponding pair of subobjects (bases and non-static data members), their values are recursively equivalent.

By removing encapsulated state and hidden pointer indirection, the language ensures that value equivalence does not depend on custom runtime operator== implementations, which might produce non-deterministic or context-dependent results. Instead, the compiler establishes template identity purely through recursive structural value equivalence. This guarantees that mangled symbol names for template instances remain consistent across separate translation units.

In practice, a class designated for NTTP usage requires constexpr constructors and public members. For example, a compile-time fixed string can be defined as:

template <std::size_t N>
struct FixedString {
    char buf[N]{};
    constexpr FixedString(const char (&str)[N]) {
        for (std::size_t i = 0; i < N; ++i) buf[i] = str[i];
    }
};

template <FixedString Str>
void print_name() {
    // Str is an NTTP of class type
}

print_name<"compile_time_string">();

Because FixedString meets all structural type criteria—it is a literal class, its members are public and non-mutable, and its internal array of characters is a structural type—the compiler can accept it as an NTTP. The compiler transparently constructs the object at compile time, inspects its subobjects, and uses its structural value to generate unique template instantiations.