C++ Template Argument Deduction

Key takeaways

Function template argument deduction: decay rules, references, arrays, perfect forwarding, CTAD overview, and how to fix deduction failures—with examples like make_pair and make_unique.

What is template argument deduction?

When you call a function template without explicit template arguments, the compiler deduces template parameters from the types of the arguments. That process is template argument deduction. APIs like std::make_pair and std::make_unique rely on it.

Basic example

template<typename T>
T add(T a, T b) {
    return a + b;
}
int main() {
    add(1, 2);      // T = int
    add(1.0, 2.0);  // T = double
    // add(1, 2.0); // error: T cannot be both int and double
    add<double>(1, 2.0);  // explicit
    return 0;
}

Deduction steps:

  1. Inspect each argument’s type.
  2. Match template parameter T to each argument.
  3. If all arguments agree on T, success; otherwise deduction fails for this template.

A deduction failure is not immediately a compile error. The template is simply removed from the set of overload candidates — the same mechanism SFINAE builds on — and the call only fails if no other candidate remains. That is why the diagnostic usually reads no matching function for call to 'add(int, double)' followed by a note such as deduced conflicting types for parameter 'T' ('int' and 'double'): the first line is the overload resolution failure, the note explains why this particular template dropped out.

Why does add(1, 2.0) fail? The first argument wants T = int, the second T = double—the deductions conflict. Crucially, the compiler does not try to reconcile them with an implicit conversion. Deduction works on exact types (with a handful of allowed adjustments such as adding const or derived-to-base for class templates); promotions like int → double that ordinary overload resolution would happily apply are not considered while T is being worked out. add<double>(1, 2.0) compiles because once T is spelled out there is nothing left to deduce, and the normal conversion rules apply to the arguments.


Deduction rules in detail

Rule 1: Pass by value ⇒ decay

For by-value parameters, top-level const, volatile, and reference qualifiers are stripped; arrays and functions decay to pointers.

template<typename T>
void by_value(T x) { (void)x; }
int i = 0;
const int ci = 10;
int& ref = i;
int arr[5];
by_value(i);    // T = int
by_value(ci);   // T = int (const dropped)
by_value(ref);  // T = int (reference stripped)
by_value(arr);  // T = int* (array decay)

Practice: Value parameters copy the argument, so cv-qualifiers on the argument do not affect T. Array size is lost after decay to pointer.

Only top-level const is dropped. Passing a const char* deduces T = const char*, because that const belongs to the pointee, not to the pointer; passing a const char* const still gives T = const char*. The decay rule is the same one auto x = expr; uses, so if you already understand why auto drops const and references, you understand by-value deduction.

String literals are where decay surprises people in practice. A literal "abc" has type const char[4]. By value it decays to const char*, so by_value("abc") gives T = const char*. By reference it does not decay, which leads to a classic error with a two-argument template: template<class T> bool same(const T&, const T&); same("ab", "abc"); fails with deduced conflicting types for parameter 'T' ('char [3]' and 'char [4]'), because the two literals have different array types. The fix is to take the parameters by value, or convert explicitly with std::string_view.

Rule 2: Pass by reference ⇒ type preserved

For reference parameters, const and reference are preserved in the deduction of T.

template<typename T>
void by_ref(T& x) { (void)x; }
int i = 0;
const int ci = 10;
by_ref(i);   // T = int
by_ref(ci);  // T = const int
// by_ref(5);  // error: cannot bind non-const lvalue ref to temporary

Tips:

  • Need to mutate: T&
  • Read-only: const T& (also binds to temporaries)
  • Perfect forwarding: T&& (forwarding reference)

With const T&, the const is already part of the parameter pattern, so T itself never picks it up: passing ci deduces T = int and the parameter type is const int&. That is the property that makes const T& the default for read-only generic parameters — T stays the “plain” type, which is what you usually want when you use T elsewhere, for example to declare a local copy.

Rule 3: Pointers

Pointers distinguish pointer-to-const vs const pointer. The low-level const (on the pointee) becomes part of T, while a top-level const on the pointer itself is dropped just as with any by-value parameter.

template<typename T>
void by_ptr(T* p) { (void)p; }
int x = 10;
const int cx = 20;
by_ptr(&x);   // T = int
by_ptr(&cx);  // T = const int

Mismatched arguments, forwarding references, arrays, and CTAD

Mismatched argument types

template<typename T>
T add(T a, T b) { return a + b; }
// add(1, 2.0);  // error

Fix 1 — explicit template argument

add<double>(1, 2.0);

Fix 2 — two template parameters

template<typename T1, typename T2>
auto add(T1 a, T2 b) {
    return a + b;
}

Fix 3 — std::common_type

template<typename T1, typename T2>
std::common_type_t<T1, T2> add(T1 a, T2 b) {
    return a + b;
}

Recommendation: Two template parameters plus auto return type is often the most flexible.

The three fixes are not equivalent. Fix 1 pushes the decision onto every caller. Fix 2 accepts anything with an operator+, including combinations you may not want (add(std::string("a"), 'b') compiles). Fix 3 computes the result type the same way the conditional operator would, which gives predictable arithmetic results but still performs a + b in whatever type the operands promote to before converting. There is a fourth option that is often the cleanest when one parameter should follow the other: mark the second parameter as a non-deduced context, e.g. template<class T> T add(T a, std::type_identity_t<T> b); (C++20). Now only a participates in deduction, T = int from add(1, 2.0), and 2.0 is converted to int as for a normal function. This is the same trick the standard uses in places where one argument should be “the real one”.

Forwarding references and reference collapsing

template<typename T>
void wrapper(T&& arg) {
    process(std::forward<T>(arg));
}
int x = 10;
wrapper(x);               // T = int&
wrapper(10);              // T = int
wrapper(std::move(x));    // T = int

For T&& in a deduced context, lvalues yield T = U& (reference collapsing); rvalues yield T = U.

This special rule only applies when T&& appears exactly in that form with T being deduced from that parameter. const T&&, std::vector<T>&&, or T&& inside a class template member where T is the class parameter are all plain rvalue references and will refuse lvalues with cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'. The deduced T is what std::forward<T> uses to restore the original value category: when T = int&, forwarding yields an lvalue; when T = int, it yields an rvalue. Writing std::forward<T>(arg) with the wrong T, or using std::move in a forwarding function, silently turns copies into moves and leaves callers’ objects in a moved-from state.

When I first wrote forwarding wrappers, the bug I kept reintroducing was forwarding the same argument twice — for example logging std::forward<T>(arg) and then passing std::forward<T>(arg) to the real function. With an rvalue argument, the first use may move from it, and the second sees an empty string or vector. Forward exactly once, at the last use.

Example — make_unique style

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
auto ptr = make_unique<Widget>(10, "hello");

make_unique shows the usual split between explicit and deduced parameters: T cannot be deduced (no function parameter mentions it), so the caller writes it, while the pack Args is deduced from the constructor arguments. Explicit template arguments fill parameters from the left, which is why “the one callers must name” goes first in such signatures.

References, const, and deduction

By value: T drops references and top-level cv. By T&: T can be const U when you pass const objects. Tip: For generic read-only APIs, prefer const T&: it binds to lvalues and temporaries, avoids copying large objects, and keeps T free of const. Take T by value only when the function needs its own copy anyway or the type is cheap to copy (iterators, small trivially copyable types).

Non-deduced contexts and braced lists

Some parameter forms never contribute to deduction. The most common is a nested type name: in template<class T> void f(typename Box<T>::value_type x);, the compiler cannot work backwards from value_type to T (many Box<T> could have the same value_type), so f(1) fails with couldn't deduce template parameter 'T'. The same applies to the left of ::, to expressions in array bounds that involve T, and to parameters whose argument is a braced list: template<class T> void g(T); g({1, 2, 3}); does not compile, even though auto x = {1, 2, 3}; deduces std::initializer_list<int>. That asymmetry between auto and templates is a known wart; if you want to accept braced lists, declare the parameter as std::initializer_list<T>.

Arrays and decay

Arrays passed by value decay; pass by reference to preserve extent:

template<typename T, size_t N>
void g(T (&a)[N]) { 
    std::cout << "Array size: " << N << '\n';
}
int arr[3] = {1, 2, 3};
g(arr);   // T = int, N = 3

Class templates: CTAD (C++17)

Constructor arguments can deduce class template parameters, e.g. std::pair, std::vector, std::optional.

std::pair p(1, 2.0);
std::vector v = {1, 2, 3};
std::optional opt(42);

CTAD runs ordinary function template deduction against a set of imaginary functions generated from the constructors, so every rule above still applies — including decay. It also has a few behaviors worth memorizing. Brace initialization prefers initializer-list constructors, so std::vector v{3, 1} is vector<int> with elements 3 and 1, while std::vector v(3, 1) is three copies of 1. Copying wins over wrapping: std::vector v2{v} where v is a vector<int> deduces vector<int> (a copy), not vector<vector<int>>. And CTAD is all-or-nothing: you cannot write std::pair<int> p(1, 2.0) to fix one parameter and deduce the other.

Tip: User-defined class templates can use deduction guides when constructor inference is ambiguous, or when a constructor’s parameters do not mention the template parameters directly (for example a constructor taking an iterator pair, where you want T to be the iterator’s value type).

template<typename T>
Container(T) -> Container<T>;

Quick fixes for common deduction failures

  • Conflicting parameter types → explicit arguments or multiple template parameters.
  • T& with const objects → T deduced as const U if you take T&; design for const T& when appropriate.
  • Many constructors → wrong type deduced; constrain with guides or factory functions.

Debugging deduction failures

Read the instantiation note to see which T was chosen, then check whether that T satisfies the template body. Add static_assert or C++20 concepts for clearer errors.

Distinguish two failure modes, because they need different fixes. If the error says no matching function with notes like template argument deduction/substitution failed, deduction itself did not produce a T — look at the argument types and the parameter pattern. If instead the error is deep inside the template body (no match for 'operator+', 'x' has no member named 'size'), deduction succeeded with a T the body cannot handle, and a concept or static_assert on T at the top of the function will move the error to the call site where it is readable.

When you are unsure what T actually became, the most reliable trick is to make the compiler tell you. Declare an undefined class template template<class T> struct TypeDisplay; and write TypeDisplay<T> probe; inside the function; the resulting error aggregate 'TypeDisplay<const int&> probe' has incomplete type (wording varies by compiler) spells out the exact deduced type, including references and cv-qualifiers that runtime tools such as typeid(T).name() strip away.


FAQ

Q1: When does deduction apply?
A: Whenever you omit explicit template arguments on a function template call. Q2: It failed—now what?
A: Specify func<int>(...), adjust argument types, or add another template parameter. Q3: Pitfalls with references?
A: const objects can make T const-qualified if you use T& and try to mutate. Q4: CTAD?
A: Use C++17 CTAD for concise vector/pair-style initialization; add guides when needed. Q5: What is a forwarding reference?
A: T&& with T deduced from an argument—used with std::forward for perfect forwarding. In one sentence: Deduction lets you omit types at call sites; knowing decay, references, arrays, and CTAD avoids common surprises.