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++11A 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:
-
Unparenthesized id-expression or class member access β
decltype(x)wherexnames a variable, function parameter, or member access. Result: the declared type of that entity, verbatim. -
Any other expression β
decltype(expr). Result depends on the value category ofexpr:- prvalue β
T - lvalue β
T& - xvalue β
T&&
- prvalue β
Understanding which form is active determines whether you get a reference or not.
Syntax
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 rulesExamples
Basic type queries
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.
// 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:
#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>::iteratorstd::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:
// 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:
std::vector<int> v{1, 2, 3};
auto elem = v[0]; // int β copy
decltype(auto) ref = v[0]; // int& β reference // C++14Comparing auto, decltype(auto), and auto&& in return position
These three forms look similar but behave differently. Given a generic wrapper:
// 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)...);
}| Form | What it returns when f returns int& | Risk |
|---|---|---|
auto | int β copy | Drops reference; silent inefficiency |
decltype(auto) | int& β reference | Safe if the referred-to object outlives the call |
auto&& | int& β forwarding ref | Can 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:
#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:
// 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:
// 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:
// 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:
// 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:
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:
// 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:
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:
// 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:
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++14decltype(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:
// 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&, thenT&& = int& && = int&β lvalue preserved. - If
T = int, thenT&& = 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:
// 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:
// 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:
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:
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:
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
int n = 5;
decltype(n) a = n; // int β declared type of n
decltype((n)) b = n; // int& β b is a reference to nAdding 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:
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:
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
decltype({1, 2, 3}) x; // error: braced-init-list is not an expressiondecltype 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
auto f = []{ return 1; };
decltype(f) g; // error before C++20: default constructor is deletedBefore 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
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:
using FooIntResult = decltype(foo(std::declval<int>())); // intStructured 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:
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
| Expression | Result | Notes |
|---|---|---|
decltype(var) | declared type | id-expression form |
decltype(prvalue) | T | e.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 expr | C++14; no stripping |
auto f() -> decltype(expr) | trailing return type | C++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 initializerconstcorrectness β whydecltypepreservesconstand reference qualifiers matters- SFINAE β detection idioms built on
decltypeand substitution failure - Constant expressions β
decltypeinconstexprandconstevalcontexts - Value categories β lvalue / prvalue / xvalue rules that drive
decltype's expression form - Structured bindings β how
decltypeinteracts with C++17 decomposition declarations