C++20 Template Lambdas: []<typename T> Syntax, Concept Constraints and When They Beat auto

Key takeaways

C++20 template lambdas: []<typename T>(T a, T b), concepts constraints, parameter packs, and when they beat generic auto lambdas.

Introduction

C++20 template lambdas let you write explicit template parameters directly on a lambda’s parameter list: []<typename T>(T a, T b) { ... }. That single syntax addition closes a gap that had existed since generic lambdas arrived in C++14. With auto parameters, each auto is deduced independently — there is no built-in way to say “these two parameters must be the same type” or “constrain this type with a concept” without falling back to a full template function object. Template lambdas fix that without giving up the compact, inline nature of a lambda.

This matters more than it looks at first glance. A huge amount of modern C++ — algorithm predicates, callback objects passed into std::visit, small utility closures captured by reference — is written as lambdas rather than named function templates, purely because lambdas are terse and locally scoped. Once you need type relationships or constraints inside that closure, you either drop back to writing a verbose functor class, or you reach for []<typename T>(...). Understanding exactly when the second option is worth it — and where it can bite you — is the point of this guide.

Basics: auto vs template lambda

#include <iostream>
#include <typeinfo>
int main() {
    // C++14: generic lambda
    auto addAuto = [](auto a, auto b) {
        return a + b;
    };
    
    // C++20: template lambda — both parameters share T
    auto addTemplate = []<typename T>(T a, T b) {
        return a + b;
    };
    
    std::cout << addTemplate(1, 2) << std::endl;
    std::cout << addTemplate(1.5, 2.5) << std::endl;
    // addTemplate(1, 2.5);  // error: mismatched T
}

The critical difference is not stylistic, it is semantic. [](auto a, auto b) desugars into a closure type whose operator() is a member template with two independent template parameters, roughly template<typename T1, typename T2> auto operator()(T1 a, T2 b). That means addAuto(1, 2.5) compiles fine — T1 is deduced as int, T2 as double, and the + inside does the usual arithmetic promotion. The compiler never checks that a and b are “the same kind of thing” because, from its point of view, there was never a requirement that they should be.

[]<typename T>(T a, T b) compiles down to template<typename T> auto operator()(T a, T b) — a single template parameter used twice. Now the compiler must deduce a single T from both arguments, and if the two call-site argument types disagree (int vs double), deduction fails with a hard error rather than silently promoting one to the other. That is exactly why the commented-out addTemplate(1, 2.5) line in the snippet above does not compile: it is not a bug in the example, it is the entire reason to prefer a template lambda over a generic one in this case — you are trading flexibility for a compile-time guarantee that the two operands are homogeneous.

Printing with type info

auto print = []<typename T>(const T& value) {
    std::cout << "type: " << typeid(T).name()
              << ", value: " << value << std::endl;
};

This pattern — binding the deduced type to a name you can then use inside the body — is the other big reason to reach for template lambdas even with a single parameter. With auto value, the compiler still deduces a type internally, but you have no named alias for it inside the lambda body. You cannot write typename T::value_type, you cannot static_assert on it, and you cannot pass it explicitly to a nested call. []<typename T>(const T& value) gives you T as a first-class name, which is what makes the container and pack examples further down possible at all.

Ideas

  • Explicit template parameters: <typename T>
  • Enforce relationships between parameters
  • Combine with concepts

Concepts

#include <concepts>
auto addInts = []<std::integral T>(T a, T b) {
    return a + b;
};
auto addFloats = []<std::floating_point T>(T a, T b) {
    return a + b;
};

Constraining T with a standard concept (std::integral, std::floating_point) is where template lambdas start earning their keep over plain auto. Without a constraint, a bug where someone passes a std::string into addInts produces a wall of template-instantiation errors pointing at the + operator deep inside the lambda body — technically correct, practically unreadable. With std::integral T, the same misuse fails immediately at the call site with a message that says the constraint was not satisfied, which is dramatically easier to act on when you are the one debugging a colleague’s call three call-sites removed from the lambda’s definition.

The trade-off is that concepts on lambda parameters only make sense when you actually want to reject certain types outright. If you are writing a generic algorithm helper that genuinely works for anything supporting +, adding std::integral for no reason just narrows your lambda’s usefulness. I treat concept constraints on lambdas as a signal to future readers (“this is deliberately restricted”), not as a habit to apply everywhere a template lambda appears.

Custom concepts

template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
auto multiply = []<Numeric T>(T a, T b) {
    return a * b;
};

Custom concepts like Numeric are worth defining once you find yourself writing the same std::integral<T> || std::floating_point<T> disjunction across multiple lambdas or function templates in the same translation unit. Defining the concept at namespace scope also documents intent in one place — anyone grepping for Numeric finds the definition and every use site, instead of having to reverse-engineer the same disjunction from several inline constraints scattered through the file.

Practical examples

Example 1: Multiple template parameters

#include <iostream>
int main() {
    auto convert = []<typename From, typename To>(From value) {
        return static_cast<To>(value);
    };
    
    auto result1 = convert.operator()<int, double>(10);
    std::cout << result1 << std::endl;
    
    auto result2 = convert.operator()<double, int>(3.14);
    std::cout << result2 << std::endl;
    
    auto toInt = []<typename From>(From value) {
        return static_cast<int>(value);
    };
    
    std::cout << toInt(3.14) << std::endl;
}

convert is the clearest illustration of why explicit template arguments exist for lambdas. To cannot be deduced from any function argument — nothing about the value 10 tells the compiler you want a double back rather than a float or a long. So this lambda is only usable through the awkward .operator()<int, double>(10) call syntax, which is precisely the syntax C++20 added support for. This is worth calling out because it trips people up the first time they see it: the .operator()<...>() spelling is not a workaround or a hack, it is the sanctioned way to supply template arguments that the compiler has no way of inferring. In contrast, toInt only has one template parameter (From), and that one is deducible from the argument, so the ordinary call syntax toInt(3.14) works without any explicit instantiation.

Example 2: Containers

#include <iostream>
#include <vector>
#include <list>
#include <typeinfo>
int main() {
    auto printContainer = []<typename Container>(const Container& c) {
        using ValueType = typename Container::value_type;
        
        std::cout << "container: " << typeid(Container).name() << std::endl;
        std::cout << "value_type: " << typeid(ValueType).name() << std::endl;
        std::cout << "elements: ";
        
        for (const auto& item : c) {
            std::cout << item << " ";
        }
        std::cout << std::endl;
    };
    
    std::vector<int> vec = {1, 2, 3, 4, 5};
    printContainer(vec);
    
    std::list<double> lst = {1.1, 2.2, 3.3};
    printContainer(lst);
}

This is the pattern I reach for most often in real code: bind the whole container type to Container, then pull value_type (or iterator, key_type, mapped_type, and so on) out of it with a using alias. With a plain auto& c parameter you can still call c.begin()/c.end() in a range-for loop just fine, but you lose the ability to name the element type explicitly — which matters the moment you need to declare a local variable of that type, forward it to another templated helper, or static_assert something about it. Note that typeid(...).name() is compiler-specific and mangled (GCC/Clang print a mangled name, MSVC prints something closer to the source spelling) — it is fine for a demo or a debug log, but never rely on its exact output for anything that has to be portable or stable across compiler versions.

Example 3: Parameter packs

#include <iostream>
int main() {
    auto sum = []<typename... Ts>(Ts... values) {
        return (values + ...);
    };
    
    std::cout << sum(1, 2, 3) << std::endl;
    std::cout << sum(1.5, 2.5, 3.5) << std::endl;
    
    auto print = []<typename... Ts>(Ts... values) {
        ((std::cout << values << " "), ...);
        std::cout << std::endl;
    };
    
    print(1, 2, 3);
    print("Hello", 42, 3.14);
}

Before C++20, writing a variadic lambda that folds over its arguments meant either nesting recursive lambda calls (painful, and it obscures the intent) or dropping the lambda entirely and writing a variadic function template. []<typename... Ts>(Ts... values) gives you the fold-expression ergonomics of a function template ((values + ...), ((std::cout << values << " "), ...)) while staying a lambda you can define inline, right where you use it — handy for a one-off logging helper or an ad hoc reduction passed into another algorithm. The thing to watch is that sum(1, 2, 3) and sum(1.5, 2.5, 3.5) each instantiate a separate specialization of the closure’s operator(), because Ts differs. If you call this lambda with many different argument-type combinations across a hot loop, you are generating that many distinct template instantiations, each compiled and potentially inlined separately — usually a non-issue, but worth remembering if you are also worried about compile times or binary size in a large translation unit.

Example 4: Container transform

#include <iostream>
#include <vector>
#include <algorithm>
int main() {
    auto transform = []<typename Container, typename Func>(
        const Container& input, 
        Func func
    ) {
        using ValueType = typename Container::value_type;
        Container output;
        
        for (const auto& item : input) {
            output.push_back(func(item));
        }
        
        return output;
    };
    
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    auto doubled = transform(numbers, [](int x) { return x * 2; });
    
    for (int n : doubled) {
        std::cout << n << " ";
    }
    std::cout << std::endl;
}

This example quietly assumes Container supports push_back and default construction — it will compile-error, not runtime-error, if you call it with a std::array (no push_back) or a std::set (push_back doesn’t exist either; you’d need insert). That is not a flaw in the code, it is the normal behavior of unconstrained templates: the failure surfaces wherever the invalid operation is used, which with a nested lambda body can mean an error message that points inside transform’s operator() rather than at your call site. If this were going into shared/production code rather than a demo, I would either constrain Container with a concept that requires push_back, or static_assert on requires { output.push_back(item); } near the top of the body so the failure message is legible to the next person who calls it with the wrong container type.

Deduction pitfalls and gotchas

A few failure modes come up often enough with template lambdas that they deserve to be listed explicitly rather than discovered the hard way:

  • Mismatched argument types with a shared T. As shown in section 1, []<typename T>(T a, T b) requires the exact same deduced type for both arguments. addTemplate(1, 2.5) does not implicitly convert one side — it fails deduction outright. If you actually want implicit promotion between related numeric types, an auto lambda (or two independent template parameters) is the right tool, not a shared T.
  • Non-deducible template parameters need explicit instantiation. Any template parameter that only appears in the return type or in a static_cast target (like To in the convert example) cannot be inferred from the call arguments. Forgetting this and calling convert(10) without .operator()<int, double>(10) produces a deduction-failure error that can look, at a glance, like the lambda itself is broken.
  • Reference vs. value deduction surprises. []<typename T>(T value) and []<typename T>(const T& value) deduce T differently for the same argument — the by-value form strips references and top-level cv-qualifiers, the reference form generally does not touch them the same way. Mixing the two styles across similar-looking lambdas in the same codebase is a common source of “why does T print differently here” confusion.
  • operator() explicit-template-argument syntax has to match the declared order. With multiple template parameters (From, To above), .operator()<int, double>(10) binds From = int, To = double, purely by position — swap the order and you silently convert in the wrong direction rather than getting an error, because both are valid types.
  • Constraints fail early, unconstrained templates fail deep. An unconstrained T used in an unsupported way (calling push_back on a std::array, for instance) fails wherever that operation appears in the body, which can be several calls deep into a nested lambda. A concept constraint at the parameter list moves that failure to the call site, with a much shorter error message — this alone is often reason enough to add a concept even when you don’t strictly need to reject any real caller.

Performance and inlining considerations

Template lambdas do not, by themselves, change performance relative to an equivalent generic (auto) lambda or a hand-written functor — both compile down to a callable object whose operator() is a template, and both are just as eligible for inlining. The performance-relevant part is what the constraint buys you indirectly: because a template lambda can force a single T across multiple parameters, you often avoid an implicit conversion that an auto lambda would have silently inserted (for example, an int being promoted to double before an addition). Removing that hidden conversion is not usually a measurable win in a single call, but it does mean the compiler generates exactly one code path per distinct T instead of also having to reason about the promoted case, which can matter for extremely hot inner loops with many call sites.

The place this genuinely does affect your binary is when a template lambda (or an auto one) gets called with many different concrete type combinations — every unique parameter-type combination is a separate instantiation of operator(), each of which the compiler compiles and, in an optimized build, may fully inline at every call site. In ordinary application code this is invisible. In a header-only library used across a large project, a template lambda with a wide variety of instantiations at different call sites can noticeably increase compile time and, if the compiler chooses not to inline every instantiation, code size (each specialization has to live somewhere). If you’re profiling a slow build and see an unusually large number of very short-lived operator()<...> symbols in -ftime-trace or similar tooling, a widely-instantiated lambda template is a reasonable place to look.

A debugging story: the .operator()<>() call that “shouldn’t” have compiled

I ran into a version of the non-deducible-parameter pitfall in a small internal conversion utility that was originally written as an auto lambda and later “upgraded” to a template lambda so it could pick between a couple of narrowing and widening conversions based on an explicit target type. The refactor added a second template parameter for the target type, mirroring the convert example above, and it compiled fine in isolation. Weeks later, someone called it through a thin wrapper function that itself was templated, and the build broke with a deduction-failure error buried under several lines of instantiation context — the kind of error that names the lambda’s closure type instead of anything a human would recognize from the source.

The actual bug was mundane once traced: the wrapper forwarded its own template parameter as the first explicit argument to .operator()<...>(), but the lambda’s parameter order had been changed in an earlier edit, so the wrapper was silently supplying the target type where the source type was expected. Both were valid, unrelated types, so nothing failed until a call site combined them in a way that made the resulting static_cast ill-formed. What made it worth remembering is that this class of bug produces no warning and no obviously wrong runtime behavior in the cases that happen to compile — it only surfaces when a caller’s type combination exposes the mismatch. Since then, whenever a template lambda takes more than one explicit template parameter, I give the parameters distinct, order-obvious names (SourceType/TargetType rather than From/To reused across several similar lambdas) specifically so a reordering mistake like that one is visible on inspection rather than only at a distant call site.

Choosing between an auto lambda and a template lambda

Scenarioauto lambdaTemplate lambda
Independent parameter types✅❌ (unless multiple Ts)
Same T for multiple parameters❌✅
Concepts on T❌✅
Explicit template args❌✅

Choosing between them comes down to one question: do you need a relationship between parameter types, or a constraint on them, that auto’s independent deduction cannot express? If yes, a template lambda is the right, lightweight tool. If every parameter is genuinely independent and unconstrained, an auto lambda is simpler and there is no reason to reach for the more verbose syntax just because it is newer.