C++ Variadic Templates — No More va_list Type Blunders
Replace va_list runtime dangers with compile-time safe variadic templates.
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
Imagine you're a chef who can cook any number of dishes in one go — whether someone orders 1 plate or 15, you have a single recipe that adapts. Variadic templates are exactly that for C++ functions and classes: one template definition that handles any number of arguments of any types, all resolved at compile time with zero runtime overhead. Before they existed, you'd have to write a separate recipe for every possible order size. Now you write it once and the compiler does the rest.
Most C++ developers reach for va_list when they need a function to handle a variable number of arguments. That's a trap—type-unsafe, fragile, and riddled with undefined behavior. Variadic templates replace that legacy mess with compile-time type safety, letting you process arbitrary argument packs without sacrificing performance or correctness.
Variadic Templates: Type-Safe Parameter Packs Without the C Legacy
Variadic templates let you write functions and classes that accept an arbitrary number of template arguments of any type, with full compile-time type checking. The core mechanic is the parameter pack — a template parameter that expands into zero or more arguments. You declare it with typename... Args and expand it with Args... inside the function body or template specialization. Unlike C-style variadic functions (va_list), there is no runtime type ambiguity, no va_arg macros, and no undefined behavior from mismatched argument types.
The compiler generates a separate instantiation for each unique combination of argument types and counts. This means overload resolution, SFINAE, and if constexpr all work naturally with packs. You can recursively process arguments by peeling off the first element and forwarding the rest, or use fold expressions (C++17) to apply an operator over all elements in a single line. The key property: everything happens at compile time — zero runtime overhead, perfect type safety.
Use variadic templates when you need type-safe heterogeneous argument lists: tuple-like containers, logging frameworks, factory functions, or delegate systems. They replace va_list entirely in new code. In production, they enable patterns like std::make_unique, std::format, and std::visit — all impossible to implement safely with C-style varargs. If you're writing a library that accepts arbitrary arguments, variadic templates are the only correct choice.
Parameter Packs — What the Compiler Actually Sees
A parameter pack is not a container. That's the most important thing to internalise before anything else. It's a compile-time placeholder that represents zero or more template arguments. The syntax typename... Types declares a template parameter pack named Types. When you later write Types... in an expression, you're expanding that pack — asking the compiler to repeat the surrounding pattern once for each type in the pack, separated by commas.
There are two flavours: template parameter packs (typename... Types) and function parameter packs (Types... args). The function parameter pack binds the actual runtime values to the compile-time types. When you write sizeof...(args) you get the count at compile time — this is resolved entirely before the program runs.
Expansion context matters enormously. You can expand a pack in a function argument list, a base class list, an initialiser list, a template argument list, and (since C++17) a fold expression. You cannot expand it in arbitrary positions — trying to use a pack without expanding it is a compile error. The pattern being expanded can be as complex as a full type transformation: std::forward<Types>(args)... expands to std::forward<T1>(a1), std::forward<T2>(a2), ... — the entire pattern repeats, not just the pack name.
f(args...) — gives no guaranteed evaluation order in C++14 and earlier. If you need strict left-to-right order (e.g., for printing or sequenced side effects), expand inside a braced initialiser list or use a fold expression with operator, in C++17. Relying on argument evaluation order is one of the stealthiest UB sources in variadic template code.Recursive Unpacking vs. C++17 Fold Expressions — Choosing the Right Tool
Before C++17, the only way to process each element in a pack was recursive template instantiation: peel the first argument off, handle it, then recurse on the rest. This works, but it creates O(N) distinct template instantiations, each with its own function body. For N=10 that's fine; for N=100 it starts to bloat compile times and binary size meaningfully.
C++17 fold expressions collapse the entire expansion into a single expression the compiler can evaluate in one pass. The syntax (args + ...) is a unary right fold — it expands to arg0 + (arg1 + (arg2 + ...)). A unary left fold (... + args) expands from the other direction. Binary folds let you supply an identity value: (0 + ... + args) ensures the expression is valid even for an empty pack.
The practical rule: prefer fold expressions for reduction operations (sum, product, logical AND/OR, string concatenation) and for simple per-element side effects using operator,. Use recursion when you need to carry state between iterations, build a data structure element-by-element with non-trivial logic, or target C++11/14 codebases. The two techniques compose well — a fold expression can call a helper that is itself a recursive template.
Production Patterns — Type-Safe Logger, tuple_for_each, and Perfect Forwarding
The real test of understanding variadic templates is applying them to problems you'd actually ship. Three patterns appear constantly in production C++ codebases: a type-safe variadic logger that avoids printf's undefined behaviour, a compile-time iteration over tuple elements, and perfect forwarding through a variadic wrapper.
Perfect forwarding with variadic templates is the backbone of factory functions, emplace methods, and middleware chains. The idiom std::forward<Types>(args)... preserves the value category (lvalue vs rvalue) of every argument independently — something impossible with C-style variadics or with a fixed-arity overload set.
Iterating over a tuple's elements requires index_sequence machinery because std::get<N> needs a compile-time constant. The helper std::index_sequence_for<Types...> generates a pack of compile-time indices matching the pack size, letting you expand a get and a callable in one shot. This pattern is the foundation of serialisation libraries, reflection utilities, and test frameworks that introspect argument types at compile time.
These three patterns together cover the vast majority of variadic template use cases you'll encounter outside of metaprogramming libraries. Master them and you'll be able to read — and contribute to — standard library implementations and frameworks like fmtlib, Boost.Hana, and range-v3.
std::forward<Types>(args) in the expansion std::forward<Types>(args)... operates independently — T0 might be an lvalue ref while T1 is an rvalue ref, and forward handles them separately. If you wrote std::move(args)... instead, you'd unconditionally move every argument, breaking callers who pass named lvalues they still need after the call. This is a question interviewers use specifically to test whether a candidate truly understands perfect forwarding versus cargo-culting it.Internals, Compile-Time Cost, and When NOT to Use Variadic Templates
Every distinct call site with a unique combination of argument types generates a separate template instantiation. Call sumAll(1, 2, 3) and sumAll(1.0, 2.0, 3.0) and the compiler stamps out two different functions. This is the source of variadic templates' zero runtime overhead — but it's also the source of compile-time and binary-size overhead that can genuinely hurt large projects.
Recursive variadic templates are especially expensive to compile. A depth-N recursive instantiation chain creates N+1 distinct types and N+1 function bodies. For deeply recursive packs (think: 20-30 elements in a mixin hierarchy or a tuple), this compounds. C++17 fold expressions mitigate this by collapsing the expansion into a single expression, dramatically reducing the number of instantiations the compiler must produce.
The right tool question is often overlooked. Use std::initializer_list<T> when all arguments are the same type — it compiles faster, binary is smaller, and the intent is clearer. Use a std::vector parameter when the count is runtime-dynamic. Reach for variadic templates specifically when you need: heterogeneous types, perfect forwarding, compile-time type introspection, or pack expansion into a base-class list. Using variadic templates where initializer_list would do is over-engineering — a real code smell in production reviews.
Variadic Arguments — The C Poison You Inherit
Before variadic templates, C++ inherited C's ... syntax and the va_list nightmare. You've seen it: va_start, va_arg, va_end. It's a type-erased abomination. The compiler has no idea what you're passing. You're on the hook to tell it the type via the second argument to va_arg. Pass the wrong type? Undefined behavior. Silent corruption. A crash three stack frames away from the actual bug.
Worse, it's not type-safe. You can pass an int and read it as a double. The compiler won't blink. The only protection is discipline. And discipline is not a language feature.
The real problem isn't just safety — it's expressiveness. You can't forward arguments. You can't inspect them at compile time. You can't iterate without a count parameter. Every function using C-style variadics is a bomb waiting for maintenance. Don't write new code with them. Don't refactor them into new interfaces. Cut them out.
C++11 gave you variadic templates. Use them. Your future self will not thank you for the va_arg debt.
int from double. The compiler emits zero diagnostics. In production, this manifests as data corruption in logging or serialization code. If you see cstdarg in a codebase, plan to replace it.Variadic Templates Beyond Functions — Classes, Aliases, and SFINAE Gates
Variadic templates aren't just for functions. They work on class templates, alias templates, and even inside decltype for type-level computation. The same typename... Args syntax applies to class parameter packs, giving you compile-time lists of types — not runtime arrays.
The canonical example: std::tuple. It's a variadic class template that holds any number of heterogeneous types. But you can build your own: a type-list for metaprogramming, a visitor pattern that dispatches on pack elements, or a variadic base class for mixin composition.
A pattern that slays in production: variadic CRTP base classes. Define a base that iterates over its own bases and calls a method on each. You get automatic delegation without virtual dispatch. Zero runtime overhead. Full type safety.
But with great power comes SFINAE complexity. When overloading variadic templates, parameter packs create ambiguity. Use enable_if or concepts (C++20) to constrain. The compiler won't guess your intent — you must gate the packs explicitly.
Alias templates also work: template <typename... T> using TypeList = SomeMetaFunction<T...>; Perfect for type transformations on packs. If you're not using variadic classes for type-level programming, you're leaving efficiency on the table.
Variadic Template Aliases — Because Typedefs Should Work at Compile Time
You already use template aliases to cut boilerplate. But most devs stop at fixed-type aliases and miss the real power: parameter packs in using declarations. An alias template with a variadic parameter lets you build type transformations that scale to any arity without recursive templates.
Production case: you have a Result<T, Error> type and need a ResultVec<...> that wraps each type in std::vector. Without variadic aliases you'd write five overloads or a macro. With them it's a single line: template <typename... T> using ResultVec = Result<std::vector<T>..., Error>;. The compiler expands each T independently — no SFINAE, no recursion, no runtime overhead.
This is not academic. I've replaced 40-line traits classes with two-line aliases in error-handling pipelines and message serializers. If your codebase still uses #define for type wrappers, you're writing C in a C++ world.
sizeof... or recursion. The compiler expands each type independently — zero overhead, zero magic.std::conditional or enable_if to build type lists, you're overcomplicating.Variadic Bases — Multiple Inheritance Without the Pain
Multiple inheritance gets a bad rap because of diamond problems and manual forwarding. Variadic base classes kill both complaints. A class can inherit from an arbitrary number of bases using a pack, and each base gets its own slot — no ambiguity, no virtual machinery.
Here's the pattern: you have a Visitor<...> that must support any set of types. Write template <typename... T> struct Visitor : public Handler<T>... {};. Each Handler<T> provides a handle(T) method. The compiler generates one base per type, and you call them by overload resolution — the pack guarantees no two bases share the same signature unless you force it.
I use this in every event dispatcher and plugin system. Pair it with CRTP for mixin-like behavior without the macro circus. One realistic example: a serialization framework where Serializer<int, float, MyCustomType> auto-generates write(int), write(float), and write(MyCustomType) from base classes. No if constexpr, no visit — just compiler-generated dispatch.
using Handler<T>::handle... and you get ambiguous calls. That using declaration is mandatory in C++17 — it's not optional sugar.Key takeaways
sizeof...(args) costs nothing at runtime because it's resolved entirely during compilation.std::forward<Types>(args)... forwards each argument independently, preserving its individual value categorystd::initializer_list<T> when all arguments share one typeInterview Questions on This Topic
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
That's C++ Advanced. Mark it forged?
8 min read · try the examples if you haven't