auto type deduction rules

C++
Author

Quasar

Published

December 21, 2025

Introduction

This weekend, I tried to solve a fun puzzle - the C++ auto type deduction gauntlet and thought of creating a quick cheatsheet on auto type deduction rules. I encourage you to give it a shot.

auto is a placeholder type that gets replaced typically by deduction from an initializer.

Whenever you have:

auto [some_modifiers] x = expression;

you have three general cases:

  • auto x : The object being assigned is allowed to decay. We strip the RHS expression of all CV-qualifiers such as const and reference modifiers & to deduce the by-value type of x.
  • auto& x or auto&& x : It preserves references and CV-qualifiers.
  • const auto& x : We specify the qualifiers, auto simply deduces the base type.

There is special treatment for expressions that are functions or arrays:

  • When initializing a reference(auto&), array/function types are preserved.
  • Otherwise, it decays to a pointer before type deduction.

Exercises to flex your understanding of auto deduction rules

Let’s determine for each of the problems:

  • The deduced type
  • The value category
  • What CV qualifiers are applicable

As the author of the puzzles states, let’s note that these are meant to test your understanding and some examples won’t compile.

Basics

auto v = 5;

This is fairly straight-forward. The deduced type is int. The value category of v is lvalue.

auto v = 0.1;

Floating-point values default to the larger double, rather than float. The value category of v is lvalue.

int x;
auto v = x;

The deduced type is int and the value category of v is lvalue.

auto i{0uz};

The u suffix is used for unsigned integer literals and the z suffix is used for the signed version of std::size_t. The suffix uz deduces to std::size_t.

The type size_t is an implementation-defined unsigned integer type that is large enough to contain the size in bytes of any object.

auto v=5, w=0.1

I didn’t get this puzzle. But, this will fail to compile. All types in an expression defined with auto have be the same.

int x;
auto v = &x;

& is the address-of operator. So, the type of v is deduced as pointer-to-int int*. The value category of v is lvalue.

int x[5];
auto v = x;

x is an array of \(5\) ints, its type is int [5]. C-style arrays decay to a pointer. So, the type of v is int*. v is an lvalue.

auto v = nullptr;

nullptr is a value of type std::nullptr_t.

auto v = {1, 2, 3};

\(1\), \(2\) and \(3\) are ints. So, the type of v is deduced as std::initializer_list<int>. If the curly-braced initializer list were {1, 2.5, 4}, type deduction would fail, because we require all types in an initializer list to be the same.

auto v{1, 2, 3};

Curly braced direct initialization only works with a single scalar value e.g. auto v{1}.

auto x = {17};

The type of x is deduced as std::initializer_list<int>.

To summarize:

  • auto + copy list initialization : If all elements are of type T, auto is deduced as std::initializer_list<T>.
  • auto + direct list initialization :
    • \(1\) element in braces \(\implies\) auto deduces the type of element by-value.
    • \(>1\) element \(\implies\) error(ill-formed).
int foo(int x){
    return x;
}

auto v = foo;

v is deduced as a function pointer, int (*) int. According to the C++ standard, a function is an object that occupies memory storage and has a lifetime. Hence, foo is an lvalue expression.

Intermediate

We now explore how references and CV-qualifiers are handled.

volatile const int x = 1;
auto v = x;

We strip all modifiers off x, so that results in an int. Thus, auto is deduced as int. auto always drops top-level CV qualifiers.

volatile const int x = 1;
auto v = &x;

I didn’t quite get this one. It turns out that CV qualifiers applied to pointed-to. So, type of x is deduced as volatile const int*. The next code snip also shows the same:

#include <cassert>
#include <type_traits>

int main(){
    const int& x{42};
    auto& y = x;
    static_assert(std::is_same_v<decltype(y), const int&>);
    return 0;
}

Compiler Explorer

int x;
int& y = x;
auto v = y;

y is an lvalue reference to x. The type of y decays to int.

int x;
auto& v = x;

The type of v is deduced as int&. v is an lvalue reference.

int x[5];
auto& v = x;

auto is deduced as int [5]. The type of v is a reference to an array of \(5\) ints.

int foo(const int x) {
    return x;
}
auto v = foo;

Functions are lvalues and the function name is a pointer to function object in memory. CV-qualifiers on parameters are thrown away during function resolution. Hence, auto is deduced as int (*)(int).

Advanced

int x;
auto&& v = x;

auto&& v is a forwarding reference. x is an lvalue and lvalues bind to lvalue references. Here, we get an lvalue reference.

auto x = [] () -> int { 
    return 1;
};
auto&& v = x();

The return value of the function call is a prvalue. It will bind to an rvalue reference. So, the type of v is deduced as int&&.

int x;
auto y = [&] () -> int& { 
    return x;
};
auto&& v = y();

The result of the function call y() is an lvalue, so the type of v is deduced to be int&.

struct Foo {};
auto&& v = Foo{};

The call to the constructor Foo{} creates a temporary Foo instance - a prvalue. Hence, the type of v is deduced as Foo&&.

void f(auto& param);

int x{22};
const int cx = x;
const int& rx = x;

f(x);   // [1]
f(cx);  // [2]
f(rx);  // [3]

auto& preserves references and CV-qualifiers. In the function call f(x), the type of param is deduced as int&. In the function call f(cx), the type of param is deduced as const int&. In the function call f(rx), the type of param is deduced as const int&.

Solving these auto type deduction puzzles prompted me to go watch Scott Meyer’s talk on type deduction from CppCon 2014. At 25:10, he says:

It is important to distinguish between an expression that is const, and an expression that contains const.

The most common place this issue occurs, is when we are dealing with pointers. Observe the following code snip:

int* const cpi;
auto p1 = cpi;

const int* pci;
auto p2 = pci;

const int* const cpci;
auto p3 = cpci;

You can have a constant pointer-to-int, in which case the expression is a constant. Or you can have a pointer-to-const, in which case you have a expression containing const. And you can have a constant pointer to const, which is a constant expression that contains a const.

When deducing auto, we are deducing by-value. The top-level const/volatile is dropped. Applying this rule, we have the following:

  • The type of p1 is deduced as int*.
  • The type of p2 is deduced as const int*.
  • The type of p3 is deduced as const int*.

THe fact that p3 is const is ignored. The fact that p3 contains const is not ignored.

int x{22};
const int* pcx = &x;
auto ppcx = &pcx;

The expression &pcx contains const. So, the type of ppcx is deduced as const int**.

Lambda capture type deduction

The captures of a lambda expression defines the outside variables that are accessible from within the lambda function body. There are three kinds of lambda capture:

  • By reference: Uses type deduction rules for reference parameters.
  • Init capture: Uses auto type deduction rules.
  • By value: CV qualifiers are retained.

Recall that, C++14 allows init-captures. You can create new data members in the closure type on the fly. Then, we can access those variables inside the lambda.

The key takeaway is that the simple by-value capture \(\neq\) by-value init capture. Observe the following code snip:

{
    const int cx{0};
    auto func = [cx](){};   // by-value capture
                            // The type of `cx` is `const int`
}

{
    const int cx{0};
    auto func = [cx = cx](){};   // init-capture
                                 // The type of `cx` is `int`
}

This is an interesting wrinkle that exists for by-value lambda captures.

Observe the following code snip:

{
    int cx{0};
    auto func = [cx]{ cx = 10; };   // error
    func();
}

In this lambda expression, cx is captured by value. The type cx is deduced as int. Recall, that the function call operator is const. You can’t modify a non-const member variable inside a const member function. So, this would be a compile error.

If you declare your lambda as mutable, it means that the function call operator is not const. Observe the next code snip. If we have a local variable which is const and we capture it by value, then it will be copied into the class as a const.

{
    const int cx{0};
    auto func = [cx] mutable { cx = 10; };  // still error
}

My next example, is a real mind-bender borrowed from slide 31 of Scott Meyer’s talk. Observe the code snip below. What is the deduced type of param?

template<typename T>
void f(const T& param);

struct Widget{};

std::vector<Widget> createVec();

const auto vw = createVec();

if(!vw.empty()){
    f(&vw[0]);
}

First, the function createVec() returns a vector<Widget> by value. auto deduction rules apply. So, the type of vw is deduced as const std::vector<Widget>. So, all elements of the vector are considered immutable and are of type const Widget. If the vector is not empty, f receives the address of the element of the vector. This is a pointer-to-const Widget, it contains const. Thus, the base type T is deduced as Widget* const. param is a const reference to T, so the type of param is deduced as const Widget* const &.

decltype type deduction

  • If you apply decltype to a (unparenthesized) name, it will give you the declared type of that name. It’s not the same as auto, because in auto deduction, top-level CV-qualifiers are dropped.
  • If the argument is any other expression of type T:
    • If the value category of the expression is xvalue, then decltype yields T&&.
    • If the value category of the expression is a prvalue, then decltype yield T.
    • If the value category of the expression is a lvalue, then decltype yields T&.

decltype merely inspects the type of the expression statically at compile-time. It does not evaluate the expression. Let’s try and solve a few more puzzles.

int x;
decltype(auto) v = (x);

The expression (x) is an lvalue, so decltype(auto) deduces to an lvalue reference of type int&.

struct Foo {};
decltype(auto) v = Foo{};

The expression Foo{} is a prvalue. For any prvalue expression e, decltype(e) evaluates to the type of e.

int x;
decltype(auto) v = std::move(x);

The expression std::move(x) is an xvalue. The type of v is deduced as int&&.

int foo(int x) {
    return x;
}
decltype(auto) v = foo;

foo is an lvalue of type int (*) (int). So, the type of v is deduced as int (*)(int).

int foo(int x) {
    return x;
}
decltype(auto) v = (foo);

(foo) is an lvalue expression of type int (*) (int). So, the type of v is deduced to be int (*) (int) &.

class Base {
    public:
        auto foo() {
            return this;
        };
};

class Derived : public Base {
};

Derived d;
auto v = d.foo();

Derived inherits foo() from Base, so you can access Base::foo() using a Derived object. Further, this in Base::foo() returns a Base*, so the type of v is deduced as Base*.

Challenge Puzzle

Observe the code snippet below. What gets printed?

// Headers

int main() {
    int x { 3 };
    decltype((x = 4)) y = x;
    std::cout << x;

    return 0;
}

For more such puzzles, visit getcracked.io.

References