TypeLists: A Deep Dive into C++ Meta-Programming

In the world of modern C++, template metaprogramming is a powerful tool for building highly generic, safe, and performant libraries. At the heart of many advanced techniques lies a simple yet profound concept: the TypeList.

A TypeList is a compile-time container that treats types themselves as data. While the concept is straightforward, building a powerful TypeList was historically a challenge. Thanks to modern C++ features, we can now create a solution that is both elegant and robust.

In this guide, we'll construct a full-featured TypeList from the ground up, exploring everything from fundamental operations to advanced compile-time algorithms, all while focusing on creating a safe and intuitive API.

About the code:

Target standard: examples assume C++20 (templated lambdas, fold expressions).

Snippets in this article focus on ideas and intentionally omit headers and scaffolding to keep things readable. If you want full, compilable code with all utilities, check the implementation in:

QkTraits

Or the full example section at the end.

The Foundation

At its heart, a TypeList is just a simple, empty wrapper around a C++ variadic template parameter pack. It holds no runtime data; its entire existence is within the type system.


// The basic structure. It's just a name for a pack of types.
template<typename ...Types>
struct TypeList {};
                

To make our TypeList safe, we need a clear way to signal failed operations (like requesting the first element of an empty list). Using void is ambiguous and can silently match overloads, so instead we define a dedicated sentinel type, InvalidType, that makes errors explicit at compile time.


// A special type to indicate a failed compile-time operation.
struct InvalidType {
    InvalidType() = delete; // Prevents misuse
};
                

With this foundation, we can start adding behavior.

Bringing TypeLists to Life: Iteration & Invocation

A list isn't useful if you can't do anything with it. The first and most important operations are iterating over the types and unpacking them.

ForEach: The Compile-Time Loop

ForEach lets us run a function for every single type in our list. It uses a C++17 fold expression to expand the code into a series of function calls at compile time.


// Inside TypeList<Types...>
template<typename Function, typename ...Args>
constexpr static void ForEach(Function&& func, Args&& ...args) {
    (func.template operator()<Types>(args...), ...);
}
                

Example: Let's say in a game engine we want to register all our components.


struct Position { /*...*/ };
struct Sprite   { /*...*/ };
struct Velocity { /*...*/ };
using MyComponents = TypeList<Position, Velocity, Sprite>;

// A generic lambda to "register" a component
auto registerComponent = []<typename Comp>() {
    std::cout << "Registering component: " << typeid(Comp).name() << std::endl;
};

MyComponents::ForEach(registerComponent);
                

InvokeWithTypesExpanded: Unpacking the List

Sometimes, you need to pass all the types in your list as template arguments to another utility, like std::variant. This function acts as a bridge.


// Inside TypeList<Types...>
template<typename Function, typename ...Args>
constexpr static decltype(auto) InvokeWithTypesExpanded(Function&& func, Args&& ...args) {
    return func.template operator()<Types...>(std::forward<Args>(args)...);
}
                

Example: Creating a std::variant that can hold any of our component types.


auto makeVariantFactory = []<typename... T>() {
    return std::variant<T...>();
};

// Unpacks Position, Velocity, Sprite into the factory's T... pack
auto componentVariant = MyComponents::InvokeWithTypesExpanded(makeVariantFactory);

// The type is now std::variant<Position, Velocity, Sprite>
                

The Utility Belt: Building the TypeList Toolkit

With the foundation laid, we can build out a complete toolkit. We'll explore the implementation of a few key utilities to understand the common patterns used in C++ metaprogramming: partial specialization, the strict template pattern, and modern constexpr evaluation.

Accessing the Front: The Front Utility

Goal: Get the very first type from the list, or InvalidType if the list is empty.

How It Works: We use a helper struct in our Internal namespace and partial template specialization. We provide a general template that handles the failure case (an empty list) and a more specific one that pattern-matches a non-empty list.


// --- Implementation in Internal namespace ---
namespace Internal {
    // This general template is chosen if no other specialization matches (i.e., for an empty list).
    template<typename... Types>
    struct FrontImpl {
        using Type = InvalidType;
    };

    // This partial specialization is chosen for any list with at least one element.
    template<typename Type1, typename... Types>
    struct FrontImpl<Type1, Types...> {
        // It captures the first type as 'Type1' and exposes it.
        using Type = Type1;
    };  
}

// Inside TypeList<Types...>
using Front = typename Internal::FrontImpl<Types...>::Type;
                

The compiler automatically selects the best-matching specialization. For TypeList<int, char>, the second version is a better match. For TypeList<>, only the first one is viable.

Adding to the List: The PushFront Utility

Goal: Create a new TypeList by adding an element to the front of an existing one.

How It Works: To ensure this operation only works on a TypeList, we use the strict template pattern. We declare a primary template but leave it incomplete. The only definition we provide is a partial specialization that exclusively accepts a TypeList.


// 1. Incomplete primary template. If a non-TypeList is passed, this is chosen,
//    and compilation fails because it has no ::Type member.
template<typename List, typename Element>
struct PushFront;

// 2. The only actual implementation.
template<typename Element, typename... Elements>
struct PushFront<TypeList<Elements...>, Element> {
    // Unpacks the existing types and prepends the new element.
    using Type = TypeList<Element, Elements...>;
};

// 3. A convenient alias for users.
template<typename List, typename Element>
using PushFront_T = typename PushFront<List, Element>::Type;
                

Removing from the List: The PopBack Utility

Goal: Create a new TypeList by removing the last element from an existing one.

How It Works: This operation is more complex because we can't directly access the "last" type in a parameter pack. The solution is a recursive template. It works by recursively peeling off the first element (Head), processing the rest of the list (Tail), and then adding the Head back to the front of the result. When the recursion reaches the base case—a list with just one element—it returns an empty list, effectively "dropping" the original last element. The implementation details are hidden in an Internal namespace, which is a common C++ practice.


// --- Implementation Details ---
namespace Internal {
    // Primary template for the implementation (forward declaration).
    template<typename List>
    struct PopBackImpl;

    // The recursive step:
    // Peels off Head, calls PopBackImpl on the Tail, and reconstructs the list.
    template<typename Head, typename... Tail>
    struct PopBackImpl<TypeList<Head, Tail...>> {
        using Type = PushFront_T<typename PopBackImpl<TypeList<Tail...>>::Type, Head>;
    };

    // Base case: When only one element is left, the result is an empty list.
    template<typename Last>
    struct PopBackImpl<TypeList<Last>> {
        using Type = TypeList<>;
    };

    // Edge case: Popping from an empty list results in an empty list.
    template<>
    struct PopBackImpl<TypeList<>> {
        using Type = TypeList<>;
    };
} // namespace Internal

// The public-facing struct that users interact with.
template<typename List>
struct PopBack {
using Type = typename Internal::PopBackImpl<List>::Type;
};

// A convenient alias for users.
template<typename List>
using PopBack_T = typename PopBack<List>::Type;

Finding a Type: The IndexOf Utility

Goal: Find the compile-time index of a type T in the list.

How It Works: Instead of complex template recursion, we can use a modern constexpr immediately-invoked lambda expression (IIFE). This is easier to read and write.


// If found returns first occurence of a type in the list
// If not found returns NPos
template <typename T>
static constexpr std::size_t IndexOf = [] {
    // Create a compile-time array of booleans.
    constexpr std::array<bool, sizeof...(Types)> matches{ std::is_same_v<T, Types>... };

    // Loop over the array at compile time to find the first 'true'.
    for (std::size_t i = 0; i < matches.size(); ++i) {
        if (matches[i]) 
            return i;       // found
    }

    return NPos;          // not found
}();
                

This entire function runs during compilation, baking the resulting index directly into your code as a constant.

The Full Toolkit

Using these same techniques—partial specialization, recursion, if constexpr, and pack expansion—the full library also provides Back, Get<N>, Contains<T>, PushBack_T, PopFront_T, PopBack_T, Concat_T, Transform_T, Reverse_T, and Filter_T, giving you a complete set of operations for any compile-time task.

Conclusion

From a simple struct, we've built a powerful, safe, and modern TypeList library. This tool allows you to shift logic from runtime to compile time, catching errors earlier, reducing boilerplate, and creating highly generic systems. It's a perfect example of the expressive power that modern C++ puts in our hands.

If you'd like to see the full implementation and the comprehensive unit tests, you can find the complete code in the QkTraits library on GitHub.

TypeList in QkTraits on GitHub

Full Example

Here’s a minimal, compilable demo putting together the ideas from this article. It shows how ForEach and InvokeWithTypesExpanded can be used in practice.


#include <iostream>
#include <variant>
#include <typeinfo>

// Simplified TypeList (conceptual)
template<typename... Types>
struct TypeList {
    template<typename Func>
    static void ForEach(Func&& f) {
        (f.template operator()<Types>(), ...);
    }

    template<typename Func>
    static auto InvokeWithTypesExpanded(Func&& f) {
        return f.template operator()<Types...>();
    }
};

struct Position {};
struct Sprite   {};
struct Velocity {};

using MyComponents = TypeList<Position, Velocity, Sprite>;

int main() {
    // ForEach
    MyComponents::ForEach([]<typename T>() {
        std::cout << "Registering: " << typeid(T).name() << "\n";
    });

    // InvokeWithTypesExpanded
    auto makeVariant = []<typename... T>() { return std::variant<T...>{}; };
    auto v = MyComponents::InvokeWithTypesExpanded(makeVariant);
}