Skip to content
C++
Language
since C++11
Intermediate

Type Deduction

How C++ deduces types for templates, auto, decltype, and decltype(auto) — the three deduction cases, reference collapsing, and pitfalls.

Type Deductionsince C++11

The compiler-performed process of determining a type from context — applied to template parameters, auto variables, decltype expressions, and function return types — following a precise set of rules that differ by form.

Overview

Type deduction in C++ is not a single mechanism. It is four overlapping systems — template argument deduction (C++98/03, extended in C++11), auto deduction (C++11), decltype (C++11), and decltype(auto) (C++14) — each with its own rules. Understanding them as a unified model avoids the confusion that arises from treating them independently.

The master rule: auto follows template deduction rules; decltype does not. Everything else is a corollary.

Template Argument Deduction — Three Cases

All auto behavior derives from template deduction, so understanding it first is essential.

cpp
template<typename T> void by_value(T param);       // Case 1
template<typename T> void by_lref(T& param);       // Case 2
template<typename T> void by_fwd(T&& param);       // Case 3

Case 1: T — deduce by value

const, volatile, &, and && are stripped from the argument type. Arrays and function types decay to pointers.

cpp
template<typename T> void f(T x);

const int ci = 42;
f(ci);              // T = int       — const stripped
f(std::move(ci));   // T = int       — const + && stripped

const int& ri = ci;
f(ri);              // T = int       — const& stripped

int arr[5];
f(arr);             // T = int*      — array decays
f("hello");         // T = const char* — string literal decays

void callback(int);
f(callback);        // T = void(*)(int) — function decays

Case 2: T& — deduce by lvalue reference

const and volatile are preserved; the deduced T never includes a reference (it lives in the & of T&). Arrays are not decayed.

cpp
template<typename T> void g(T& x);

int i = 0;
const int ci = 0;
const int& ri = ci;

g(i);               // T = int,       param = int&
g(ci);              // T = const int, param = const int&
g(ri);              // T = const int, param = const int& (ref in arg ignored)

int arr[5];
g(arr);             // T = int[5],    param = int(&)[5] — no decay

const T& (Case 2 variant) is identical to T& for deduction purposes — const in the parameter type doesn't affect T; it's just part of the parameter.

Case 3: T&& — forwarding reference (universal reference)

Only applies when T&& appears directly as a function parameter with a deduced T. Reference collapsing determines the final type.

cpp
template<typename T> void h(T&& x);

int i = 0;
const int ci = 0;

h(i);               // lvalue: T = int&,        T&& collapses to int&
h(ci);              // lvalue: T = const int&,  T&& collapses to const int&
h(42);              // rvalue: T = int,          T&& stays int&&
h(std::move(i));    // rvalue: T = int,          T&& stays int&&

Reference collapsing rules (C++11): & & → &, & && → &, && & → &, && && → &&. Any & in the combination wins except && &&.

auto Deduction (C++11)

auto maps directly onto the three template cases. The auto keyword substitutes for T.

cpp
const int ci = 42;
int arr[5];

// Case 1 — by value
auto a1 = ci;          // int       (const stripped)
auto a2 = arr;         // int*      (decays)

// Case 2 — by lvalue ref
auto& a3 = ci;         // const int& (const preserved)
auto& a4 = arr;        // int(&)[5]  (no decay)

// Case 3 — forwarding ref
auto&& a5 = ci;        // const int& (lvalue → lvalue ref)
auto&& a6 = 42;        // int&&      (rvalue → rvalue ref)

auto in range-for (C++11)

The deduction rules explain the three idiomatic forms:

cpp
std::map<std::string, int> m{{"a", 1}, {"b", 2}};

for (auto p : m)          // pair<const string,int>  — COPY each iteration
    p.second++;           // modifies copy, not map

for (auto& p : m)         // pair<const string,int>& — modifies map
    p.second++;

for (const auto& p : m)   // read-only ref, no copy
    process(p);

Initializer list — special case (C++11/C++17)

auto with a braced-init-list has its own rule, partially revised in C++17:

cpp
auto a  = {1, 2, 3};   // C++11/14/17: std::initializer_list<int>
auto b  = {42};         // C++11/14/17: std::initializer_list<int>
auto c{42};             // C++11/14: std::initializer_list<int>
                        // C++17+:   int  (direct-init deduces scalar)
auto d{1, 2};           // C++11/14: std::initializer_list<int>
                        // C++17+:   ill-formed (direct-init requires one element)

decltype (C++11)

decltype does not follow template deduction rules. It preserves exact declared types and encodes value category.

Two distinct forms:

cpp
int x = 0;
const int cx = 0;
const int& rx = cx;

// Form 1: decltype(entity) — declared type of the named entity
decltype(x)   d1;   // int
decltype(cx)  d2;   // const int (not default-constructible, shown for type)
decltype(rx)  d3 = cx; // const int&

// Form 2: decltype(expression) — type plus value category
// lvalue expr    → T&
// xvalue expr    → T&&
// prvalue expr   → T
decltype((x))   d4 = x;    // int& — (x) is an lvalue expression
decltype(x + 0) d5;        // int  — x+0 is a prvalue
decltype(std::move(x)) d6 = std::move(x); // int&&

The parenthesised-name form decltype((name)) is a notorious footgun. decltype(x) and decltype((x)) yield int and int& respectively for the same x.

Trailing return types (C++11)

Before decltype(auto) existed, trailing returns were the way to forward return types precisely:

cpp
template<typename C>
auto front(C& c) -> decltype(c.front()) {
    return c.front();
}
// If c.front() returns int&, front() returns int&
// Works in C++11; C++14 allows decltype(auto) instead

decltype(auto) (C++14)

decltype(auto) applies decltype rules to the initializer expression rather than template rules. It is the correct tool for perfectly forwarding return types without writing them explicitly.

cpp
int x = 5;
const int& rx = x;

auto           a = rx;   // int        — template rules strip const&
decltype(auto) d = rx;   // const int& — decltype rules preserve it

Forwarding return types (C++14)

cpp
// auto strips — wrong when the return expression is a reference
auto bad(std::vector<int>& v) { return v[0]; }        // int, copy
auto& fragile(std::vector<int>& v) { return v[0]; }   // int&, but manual

// decltype(auto) propagates exactly what the expression is
decltype(auto) correct(std::vector<int>& v) { return v[0]; }  // int&

// Works in generic forwarding wrappers
template<typename F, typename... Args>
decltype(auto) call(F&& f, Args&&... args) {
    return std::forward<F>(f)(std::forward<Args>(args)...);  // C++11
}

Common Pitfalls

auto makes unexpected copies

cpp
std::vector<std::string> words = get_words();

auto w = words[0];   // std::string — copy
auto& r = words[0];  // std::string& — reference, no copy

This is especially costly in range-for over containers of heavy objects.

decltype((x)) returns a dangling reference

cpp
decltype(auto) dangling() {
    int x = 42;
    return (x);   // decltype((x)) = int& — returns reference to local!
}

decltype(auto) safe() {
    int x = 42;
    return x;     // decltype(x) = int — returns by value
}

This is an easy mistake in decltype(auto) functions. Always audit the return expression.

Forwarding references require deduced context

T&& is a forwarding reference only when T is deduced at the call site. If T is fixed, T&& is a plain rvalue reference:

cpp
template<typename T>
void fwd(T&& x);           // forwarding reference — T deduced

void rval(std::string&& x); // rvalue reference — not a forwarding ref

template<typename T>
struct S {
    void mem(T&& x);       // rvalue reference — T fixed at class instantiation
};

auto in structured bindings (C++17)

In C++17 structured bindings, auto controls the binding specifier, not the individual element types:

cpp
auto [a, b] = std::pair{1, 2.0};   // copies the pair; a=int, b=double
auto& [c, d] = some_pair;          // binds to pair by ref
const auto& [e, f] = some_pair;    // const ref

The element types are always determined by the type of the aggregate/tuple, not independently by auto.

Best Practices

  • Use auto& or const auto& in range-for by default; only use auto when you explicitly need a copy.
  • Use decltype(auto) in generic forwarding wrappers and proxy return types; use explicit auto/auto& where the type is obvious and you want the stripping behaviour.
  • Never use decltype(auto) on a parenthesised local variable in a return statement.
  • Prefer auto&& in generic lambdas (C++14) and range-for when the element type is uncertain — it binds to anything without copying.
  • Tag forwarding reference parameters with a comment or a concept (C++20) to distinguish them from intentional rvalue references.

Template argument deduction rules

Template deduction succeeds only when the deduced type can be determined unambiguously from the argument. Ambiguity — such as two parameters deducing conflicting types for the same T — is a hard error.

cpp
// C++11
template<typename T>
void add(T a, T b);

add(1, 2);        // OK: T = int
add(1, 2.0);      // Error: T deduced as int from first arg, double from second

// Fix: explicit cast or explicit template argument
add(1, static_cast<int>(2.0));  // OK: T = int
add<double>(1, 2.0);            // OK: T = double

Deduction does not cross implicit conversions. A parameter of type std::vector<T> cannot deduce T from an std::initializer_list argument.

cpp
// C++11
template<typename T>
void fill(std::vector<T> v);

fill({1, 2, 3});   // Error: cannot deduce T from braced-init-list
fill(std::vector<int>{1, 2, 3});  // OK: T = int

Non-deduced contexts also prevent deduction. Anything appearing on the left side of ::, inside a nested type, or inside a non-type template parameter is non-deduced.

cpp
// C++11
template<typename T>
void process(typename std::vector<T>::value_type x);  // T is non-deduced here

process(42);           // Error: cannot deduce T
process<int>(42);      // OK: T = int, explicit

auto in structured bindings

In structured bindings (C++17), auto qualifies the invisible initializer object, not each individual name. The element types come from the decomposed type, not from the auto specifier.

cpp
// C++17
std::pair<int, double> p{1, 2.5};

auto [a, b] = p;           // copies p; a is int, b is double
auto& [c, d] = p;          // c and d refer into p — modifying c changes p.first
const auto& [e, f] = p;    // read-only access, no copy

// Works with arrays
int arr[3] = {10, 20, 30};
auto [x, y, z] = arr;      // copies arr; x, y, z are int
auto& [r, s, t] = arr;     // r, s, t refer into arr

Structured bindings work with any type that satisfies the tuple protocol (std::get, std::tuple_size) or is an aggregate, or has accessible data members.

cpp
// C++17
struct Point { int x; int y; };

Point pt{3, 7};
auto& [px, py] = pt;   // px binds to pt.x, py binds to pt.y
px = 10;               // pt.x is now 10

// With std::map iteration — the standard pattern
std::map<std::string, int> scores{{"alice", 95}, {"bob", 87}};
for (const auto& [name, score] : scores) {
    // name is const std::string&, score is const int&
}

auto&& in a structured binding creates forwarding references into the decomposed object, useful in generic code.

cpp
// C++17
auto&& [key, val] = *some_map.find("key");  // binds without copying

auto function return types

auto as a return type (C++14) deduces from the return expression using template-deduction rules, stripping const, volatile, and reference qualifiers. Every return statement in the function must deduce the same type.

cpp
// C++14
auto square(int x) { return x * x; }          // deduced: int
auto make_pair(int a, double b) {
    return std::make_pair(a, b);               // deduced: std::pair<int, double>
}

// Multiple return statements must agree
auto classify(int x) {
    if (x > 0) return  1;   // int
    if (x < 0) return -1;   // int — consistent
    return 0;               // int — consistent
}

Recursive functions need at least one return statement that does not depend on the recursive call for deduction to succeed.

cpp
// C++14
auto factorial(int n) {
    if (n <= 1) return 1;                  // base case deduced first → int
    return n * factorial(n - 1);           // consistent with int
}

Lambdas also support auto return deduction from C++11 (single-expression) and C++14 (multi-statement body).

cpp
// C++11 — single-expression lambda, return type inferred
auto square = [](int x) { return x * x; };

// C++14 — multi-statement body
auto clamp = [](double v, double lo, double hi) {
    if (v < lo) return lo;
    if (v > hi) return hi;
    return v;
};

Trailing return types

Trailing return types (C++11) let the return type reference parameter names, which is impossible with a leading return type. They use the syntax auto name(params) -> ReturnType.

cpp
// C++11 — return type depends on parameter type
template<typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
    return t + u;
}

// Without trailing syntax this would require repeating the type or using decltype
// with parameter copies — trailing return makes it clean

Trailing returns are still useful in C++14+ when the return type is complex and spelling it out improves readability, or when it depends on a type alias in the class.

cpp
// C++11 — useful with complex function pointer return types
auto get_callback() -> void(*)(int, int);   // clearer than: void(*get_callback())(int,int)

// C++11 — member function returning type from the class's own scope
struct Widget {
    using Handle = int;
    auto id() const -> Handle { return id_; }   // Handle is in scope after ->
private:
    Handle id_;
};

In C++20, abbreviated function templates use trailing returns naturally with auto parameters.

cpp
// C++20 — abbreviated template with trailing return type
auto transform(auto container, auto fn) -> decltype(container) {
    for (auto& item : container) item = fn(item);
    return container;
}

decltype and expression types

decltype encodes both the type and the value category of an expression, making it the precise tool for type introspection. The three value-category outcomes follow a simple rule: lvalue → T&, xvalue → T&&, prvalue → T.

cpp
// C++11
int x = 0;
int* p = &x;

decltype(*p)       r1 = x;   // int&   — *p is an lvalue
decltype(std::move(x)) r2 = std::move(x); // int&& — move() is an xvalue
decltype(x + 1)    v1;       // int    — x+1 is a prvalue

// Member access
struct S { int m; };
S s;
decltype(s.m)  m1;    // int    — named entity, Form 1
decltype((s.m)) m2 = s.m;  // int& — expression, Form 2 (lvalue)

decltype is the backbone of type-safe generic programming. It appears in SFINAE conditions, std::result_of replacements, and concept definitions.

cpp
// C++11 — SFINAE with decltype in trailing return
template<typename T>
auto size(const T& t) -> decltype(t.size(), std::size_t{}) {
    return t.size();   // enabled only if t.size() is valid
}

// C++20 — concept using decltype
template<typename T>
concept Sizeable = requires(T t) {
    { t.size() } -> std::convertible_to<std::size_t>;
};

std::declval<T>() extends decltype to work on types that cannot be constructed, enabling type queries purely at compile time.

cpp
// C++11
template<typename T, typename U>
using AddResult = decltype(std::declval<T>() + std::declval<U>());

// AddResult<int, double> is double — no T or U object ever constructed

Type deduction with forwarding references

Forwarding references (T&& with deduced T) preserve the value category of the argument, enabling perfect forwarding. They are the mechanism behind std::forward and generic factory functions.

cpp
// C++11
template<typename T>
void relay(T&& arg) {
    // T deduced as lvalue-ref when arg is lvalue, plain type when rvalue
    use(std::forward<T>(arg));   // forwards the original value category
}

int x = 10;
relay(x);           // T = int&,  arg = int&,  forwards as lvalue
relay(42);          // T = int,   arg = int&&, forwards as rvalue
relay(std::move(x)); // T = int,  arg = int&&, forwards as rvalue

std::forward<T> is a conditional cast: it casts to T&& if T is a non-reference type (rvalue case) and returns an lvalue reference otherwise — it is a no-op for lvalues.

cpp
// C++11 — perfect forwarding in a generic factory
template<typename T, typename... Args>
T* create(Args&&... args) {
    return new T(std::forward<Args>(args)...);  // each arg forwarded as original category
}

// C++20 — forwarding reference in a lambda (abbreviated template)
auto wrapper = []<typename T>(T&& val) {
    return process(std::forward<T>(val));
};

auto&& in range-for and generic lambdas acts as a forwarding reference. It avoids copies while remaining agnostic about value category.

cpp
// C++14 — generic lambda with forwarding reference parameter
auto log_and_call = [](auto&& fn, auto&&... args) {
    log("calling");
    return std::forward<decltype(fn)>(fn)(std::forward<decltype(args)>(args)...);
};

// C++17 — auto&& in range-for avoids copies for proxy iterators (e.g. vector<bool>)
for (auto&& elem : container) {
    process(std::forward<decltype(elem)>(elem));
}

See Also