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

decltype

The decltype specifier queries the declared type of an entity or the exact type of an expression at compile time, without evaluation.

decltypesince C++11

A compile-time specifier that yields the declared type of an entity or the exact type of an expression β€” including value category β€” without evaluating the operand.

Overview

decltype fills the gap that auto cannot. Where auto derives a type from an initializer and strips references and cv-qualifiers, decltype preserves the complete type of whatever you give it: references, const, volatile, and all. The operand is an unevaluated expression β€” no code is generated, no side effects occur.

There are two distinct semantic forms, and the distinction is load-bearing:

  1. Unparenthesized id-expression or class member access β€” decltype(x) where x names a variable, function parameter, or member access. Result: the declared type of that entity, verbatim.

  2. Any other expression β€” decltype(expr). Result depends on the value category of expr:

    • prvalue β†’ T
    • lvalue β†’ T&
    • xvalue β†’ T&&

Understanding which form is active determines whether you get a reference or not.

Syntax

cpp
decltype(entity)      // unparenthesized id-expression or member access
decltype(expression)  // any other expression β€” value-category rules apply
decltype(auto)        // C++14: placeholder deduced with decltype rules

Examples

Basic type queries

cpp
int x = 0;
const int& cr = x;

// Form 1: id-expression β€” declared type returned verbatim
static_assert(std::is_same_v<decltype(x),  int>);
static_assert(std::is_same_v<decltype(cr), const int&>);

// Form 2: expression β€” value category determines result
static_assert(std::is_same_v<decltype(42),  int>);   // prvalue β†’ int
static_assert(std::is_same_v<decltype(x+0), int>);   // prvalue β†’ int
static_assert(std::is_same_v<decltype((x)),  int&>);  // lvalue expression β†’ int&

The final line is the notorious double-parenthesis rule: adding an extra pair of parentheses around a variable name makes it a plain lvalue expression rather than an id-expression, so decltype applies expression rules and returns a reference. This distinction is explored in detail in the pitfalls section.

Trailing return types (C++11)

Before C++14 introduced return-type deduction, writing a function whose return type depends on its own arguments required a trailing return type. The reason: function parameters are not in scope for a leading return type, but they are in scope after the parameter list closes.

cpp
// C++11 β€” parameters c and i are in scope after ->
template <typename Container, typename Index>
auto at(Container& c, Index i) -> decltype(c[i])
{
    return c[i];
}

For most standard containers, c[i] is an lvalue, so the deduced return type is a reference β€” exactly what operator[] returns. This generalises to any expression involving parameters.

The std::declval trick extends this to types that are not constructible from the call site. std::declval<T>() produces a hypothetical rvalue reference to T in an unevaluated context, enabling member probing without requiring a constructor:

cpp
#include <utility>  // std::declval

// Return type of T::begin() without constructing T
template <typename T>
using IteratorOf = decltype(std::declval<T>().begin());  // C++11

// Works even if T has no default constructor
using VecIter = IteratorOf<std::vector<int>>;  // std::vector<int>::iterator

std::declval is only valid inside unevaluated operands (decltype, sizeof, noexcept, alignof). Using it in an evaluated expression is ill-formed.

decltype(auto) β€” preserve references and cv-qualifiers (C++14)

Plain auto return-type deduction follows template argument deduction rules: it strips top-level references and cv-qualifiers. A forwarding wrapper written with auto silently makes copies:

cpp
// C++14
struct Buffer {
    std::string data;
    std::string& get() { return data; }
};

auto           bad_proxy(Buffer& b)  { return b.get(); }  // returns std::string (copy!)
decltype(auto) good_proxy(Buffer& b) { return b.get(); }  // returns std::string& (reference)

decltype(auto) tells the compiler: "deduce this type, but apply decltype rules to the return expression rather than template-argument-deduction rules." The same applies to variables:

cpp
std::vector<int> v{1, 2, 3};

auto           elem = v[0];   // int   β€” copy
decltype(auto) ref  = v[0];   // int&  β€” reference  // C++14

Comparing auto, decltype(auto), and auto&& in return position

These three forms look similar but behave differently. Given a generic wrapper:

cpp
// C++14
template <typename F, typename... Args>
auto call_auto(F&& f, Args&&... args) {
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

template <typename F, typename... Args>
decltype(auto) call_dctauto(F&& f, Args&&... args) {
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

template <typename F, typename... Args>
auto&& call_fwdref(F&& f, Args&&... args) {
    return std::forward<F>(f)(std::forward<Args>(args)...);
}
FormWhat it returns when f returns int&Risk
autoint β€” copyDrops reference; silent inefficiency
decltype(auto)int& β€” referenceSafe if the referred-to object outlives the call
auto&&int& β€” forwarding refCan bind to temporaries; dangling if f returns a prvalue

decltype(auto) is the right choice for perfect-forwarding wrappers. It preserves the return type exactly without the dangling-reference risk of auto&&.

Template metaprogramming and SFINAE

decltype is the foundation of detection idioms. Combined with std::declval you can probe member existence in unevaluated contexts:

cpp
#include <type_traits>
#include <utility>

template <typename, typename = void>
struct has_serialize : std::false_type {};

template <typename T>
struct has_serialize<
    T,
    std::void_t<decltype(std::declval<T>().serialize())>  // C++17
> : std::true_type {};

struct Serializable { std::string serialize() const { return "{}"; } };
struct Plain {};

static_assert( has_serialize<Serializable>::value);
static_assert(!has_serialize<Plain>::value);

std::declval<T>() gives you a "fake" T to call methods on inside decltype. Neither expression is ever executed. The substitution failure β€” when T has no serialize β€” causes the specialisation to be silently discarded, leaving the false_type base.

Expression SFINAE with enable_if

The same pattern works directly with std::enable_if in C++11/14 when std::void_t is not yet available:

cpp
// C++11 β€” enable_if version
template <typename T,
          typename = decltype(std::declval<T>().serialize())>
void write(T const& obj) {
    send(obj.serialize());
}

// Overload selected when T has no .serialize()
template <typename T>
void write(...) {
    send(default_representation<T>());
}

The key is that decltype(std::declval<T>().serialize()) is a dependent expression. If it is ill-formed for a given T, the overload is silently removed from the candidate set β€” this is expression SFINAE.

C++20 requires-expressions replace the idiom

C++20 requires expressions are a cleaner alternative. The two are semantically equivalent but requires-expressions are far more readable:

cpp
// C++20 β€” requires expression
template <typename T>
concept Serializable = requires(T obj) {
    { obj.serialize() } -> std::convertible_to<std::string>;
};

template <Serializable T>
void write(T const& obj) { send(obj.serialize()); }

Prefer requires in new C++20 code; use the decltype + void_t pattern when targeting C++17 or earlier.

Detecting expression well-formedness: the detection idiom

The detection idiom formalises the void_t pattern into a reusable utility. It was proposed for the standard library as std::is_detected (Library Fundamentals v2) but never merged. Implementing it manually is straightforward:

cpp
// C++17
#include <type_traits>

// Primary template β€” "not detected"
template <typename Default, typename AlwaysVoid,
          template <typename...> class Op, typename... Args>
struct detector {
    using value_t = std::false_type;
    using type    = Default;
};

// Specialisation β€” "detected" when Op<Args...> is valid
template <typename Default,
          template <typename...> class Op, typename... Args>
struct detector<Default, std::void_t<Op<Args...>>, Op, Args...> {
    using value_t = std::true_type;
    using type    = Op<Args...>;
};

struct nonesuch {};  // sentinel for "not detected"

template <template <typename...> class Op, typename... Args>
using is_detected = typename detector<nonesuch, void, Op, Args...>::value_t;

template <template <typename...> class Op, typename... Args>
using detected_t  = typename detector<nonesuch, void, Op, Args...>::type;

// --- Usage ---
template <typename T>
using serialize_t = decltype(std::declval<T>().serialize());

static_assert( is_detected<serialize_t, Serializable>::value);
static_assert(!is_detected<serialize_t, Plain>::value);

// detected_t gives the actual return type, or nonesuch if absent
static_assert(std::is_same_v<detected_t<serialize_t, Serializable>, std::string>);

The decltype inside the alias template serialize_t is the active ingredient: std::void_t discards the type but forces substitution, causing failures to propagate to the specialisation selector.

In C++20, the whole pattern collapses to a single requires { expr; } constraint:

cpp
// C++20 equivalent β€” no detector machinery needed
template <typename T>
concept HasSerialize = requires(T t) { t.serialize(); };

Naming lambda types

Lambda closure types have no user-writable name. decltype is the only portable way to name them:

cpp
auto cmp = [](const std::string& a, const std::string& b) {
    return a.size() < b.size();
};

// Use the exact closure type as the comparator
std::set<std::string, decltype(cmp)> by_length(cmp);

Before C++20, lambda types have a deleted default constructor, so the instance must be passed explicitly. Since C++20, captureless lambdas are default-constructible and you can omit the constructor argument:

cpp
// C++20: captureless lambda β€” default constructible
std::set<std::string, decltype(cmp)> by_length;

Custom deleters for unique_ptr

The deleter type is part of unique_ptr's template parameters. decltype avoids the overhead of std::function when the deleter is a lambda or function pointer:

cpp
auto file_closer = [](FILE* f) { if (f) std::fclose(f); };
using FilePtr = std::unique_ptr<FILE, decltype(file_closer)>;

FilePtr open_file(const char* path) {
    return FilePtr{std::fopen(path, "r"), file_closer};
}

Without decltype here, you would fall back to std::function<void(FILE*)>, which allocates on the heap. decltype keeps the type exact and the size known at compile time.

decltype(auto) in range-based for loops

When iterating over a container of non-trivial elements, the loop variable's type matters for performance and correctness. decltype(auto) in a range-based for loop binds to exactly what the iterator dereferences to β€” preserving proxy references that auto& would not:

cpp
// C++14
std::vector<int> v{1, 2, 3};

for (auto x : v)            // int β€” copy every element
    x = 0;                  // modifies local copy only

for (auto& x : v)           // int& β€” reference, modifies v
    x = 0;

for (decltype(auto) x : v)  // int& β€” identical to auto& for vector<int>
    x = 0;

The real difference appears with containers whose operator* returns a proxy rather than a plain reference. std::vector<bool> is the classic example:

cpp
std::vector<bool> flags{true, false, true};

for (auto x : flags)            // bool β€” copies the proxy; read-only
    ;

for (auto& x : flags)           // ill-formed: cannot bind non-const lref to proxy
    ;

for (decltype(auto) x : flags)  // std::vector<bool>::reference β€” the proxy itself
    x = false;                  // correctly mutates flags via the proxy  // C++14

decltype(auto) is the only loop form that works uniformly with both plain references and proxy references.

How std::forward uses decltype

std::forward<T> must return an lvalue reference when T is an lvalue reference type, and an rvalue reference when T is a plain type (indicating an rvalue was passed). The standard implementation relies on static_cast rather than decltype directly, but understanding the type deduction is instructive:

cpp
// Simplified implementation of std::forward (C++11)
template <typename T>
T&& forward(std::remove_reference_t<T>& arg) noexcept {
    return static_cast<T&&>(arg);
}

Here, T&& collapses via reference-collapsing rules:

  • If T = int&, then T&& = int& && = int& β€” lvalue preserved.
  • If T = int, then T&& = int&& β€” rvalue cast applied.

The caller's type T is the carrier of value-category information. In a forwarding wrapper you capture this via decltype:

cpp
// C++11 β€” manual forwarding using decltype
template <typename F, typename Arg>
decltype(auto) wrap(F&& f, Arg&& arg) {
    // decltype(arg) is Arg& or Arg&& depending on the call site
    return std::forward<F>(f)(std::forward<Arg>(arg));
}

decltype(arg) inside the body would yield Arg& (because named parameters are lvalues), whereas the declared type Arg carries the forwarding-reference information. This is why std::forward takes an explicit T rather than deducing from decltype.

Structured bindings with decltype (C++17)

In C++17 structured bindings, the declared names are not ordinary variables β€” they are bindings to sub-objects. decltype on a structured binding name reflects whether the binding is to a reference or a copy:

cpp
// C++17
#include <tuple>

std::tuple<int, double> t{1, 3.14};

auto [a, b] = t;             // a: int (copy),   b: double (copy)
auto& [c, d] = t;            // c: int& (ref),   d: double& (ref)
const auto& [e, f] = t;      // e: const int&,   f: const double&

static_assert(std::is_same_v<decltype(a), int>);          // not a reference
static_assert(std::is_same_v<decltype(c), int>);          // int β€” declared type of c
// But the underlying binding IS a reference; c itself acts as int&
// decltype((c)) applies expression rules: lvalue β†’ int&
static_assert(std::is_same_v<decltype((c)), int&>);

The wrinkle: decltype(c) gives the binding's declared type (which is int, not int& even though c is bound to get<0>(t)), but decltype((c)) applies lvalue-expression rules and gives int&. This matches the general double-parenthesis rule but is especially confusing because c looks like a reference yet decltype(c) does not say so.

When the structured binding uses auto&&, it becomes a forwarding binding:

cpp
auto&& [g, h] = t;  // g and h are forwarding references
// If t is an lvalue, g is int&; if t is an rvalue, g is int&&
static_assert(std::is_same_v<decltype(g), int>);   // declared type: int
static_assert(std::is_same_v<decltype((g)), int&>); // expression type: int&

Best Practices

Use decltype(auto) for any forwarding wrapper. Functions that proxy another's return value β€” getters, decorators, cache layers β€” must use decltype(auto) or they silently copy and drop references.

Prefer decltype over non-standard typeof. The GCC/Clang __typeof__ extension predates C++11 and is not portable. decltype is standard and better-specified.

Alias complex decltype expressions. When the same decltype expression appears more than once, give it a name:

cpp
using Iter = decltype(std::declval<std::vector<int>>().begin());

Use std::declval inside decltype for type probing. Never construct objects just to access members in a type context. std::declval is the right tool for unevaluated operands.

Combine with if constexpr in C++17 instead of SFINAE overload sets where clarity matters:

cpp
template <typename T>
void process(T& obj) {
    if constexpr (has_serialize<T>::value) {   // C++17
        send(obj.serialize());
    } else {
        send(default_representation(obj));
    }
}

Prefer requires-expressions over the detection idiom in C++20. Both check the same thing, but requires-expressions produce better error messages and require no helper machinery.

Common Pitfalls

The extra-parenthesis trap

cpp
int n = 5;
decltype(n)   a = n;   // int  β€” declared type of n
decltype((n)) b = n;   // int& β€” b is a reference to n

Adding parentheses changes which semantic form applies. This is the most common source of surprising decltype behaviour, and the error compounds when decltype(auto) is involved:

cpp
decltype(auto) dangerous() {
    int local = 42;
    return (local);   // int& β€” dangling reference to destroyed local!
}

decltype(auto) safe() {
    int local = 42;
    return local;     // int β€” copy, correct
}

The extra parentheses in the return expression trigger the lvalue-expression form, yielding int&. The resulting reference dangles immediately. Clang and GCC both emit a warning for this pattern, but it is still valid (and wrong) C++.

The same trap appears inside member functions. Returning decltype((member)) deduces a reference to a member of the current object:

cpp
struct Widget {
    int value = 0;

    // Dangerous: decltype((value)) is int& β€” reference to member
    decltype(auto) get_bad() { return (value); }   // int& β€” fine as long as Widget lives
                                                    // but confusing and fragile

    // Clear intent: explicit return type
    int& get_good() { return value; }
};

Always review every return statement in a decltype(auto) function for accidental parenthesisation.

Brace-init-lists are not expressions

cpp
decltype({1, 2, 3}) x;  // error: braced-init-list is not an expression

decltype requires an expression or a named entity. A braced-init-list is neither, so it cannot appear as the operand.

Lambda default-construction before C++20

cpp
auto f = []{ return 1; };
decltype(f) g;   // error before C++20: default constructor is deleted

Before C++20, closure types have deleted default constructors. Use std::function if you need a default-constructible callable wrapper, or upgrade the standard mode.

decltype on overloaded functions

cpp
int   foo(int);
float foo(float);

decltype(foo) x;  // error: foo is overloaded; which type?

decltype on an overloaded function name without arguments is ill-formed because there is no single type to return. You must write a full call expression β€” which requires std::declval if no arguments are at hand:

cpp
using FooIntResult = decltype(foo(std::declval<int>()));  // int

Structured binding decltype surprise

As shown above, decltype(binding_name) gives the declared type, not the reference type, even when the binding refers to an element of the original object. If you need the reference type, use decltype((binding_name)) β€” but be aware this always gives a reference, even for copy bindings, because the name is an lvalue:

cpp
auto [x, y] = std::pair{1, 2.0};  // copy binding

static_assert(std::is_same_v<decltype(x),    int>);   // declared: int
static_assert(std::is_same_v<decltype((x)),  int&>);  // expression: int& (x is lvalue)

Quick Reference

ExpressionResultNotes
decltype(var)declared typeid-expression form
decltype(prvalue)Te.g. decltype(42) β†’ int
decltype(lvalue_expr)T&e.g. decltype((x)) β†’ int&
decltype(xvalue_expr)T&&e.g. decltype(std::move(x)) β†’ int&&
decltype(auto) v = expr;exact type of exprC++14; no stripping
auto f() -> decltype(expr)trailing return typeC++11; params in scope
decltype(std::declval<T>().m())return type of T::m()unevaluated; no ctor needed

See Also

  • auto β€” complementary type deduction that strips references and cv-qualifiers from the initializer
  • const correctness β€” why decltype preserves const and reference qualifiers matters
  • SFINAE β€” detection idioms built on decltype and substitution failure
  • Constant expressions β€” decltype in constexpr and consteval contexts
  • Value categories β€” lvalue / prvalue / xvalue rules that drive decltype's expression form
  • Structured bindings β€” how decltype interacts with C++17 decomposition declarations