Skip to content
C++
Idiom
since C++11
Advanced

Detection Idiom

Compile-time trait detection via SFINAE and void_t to test whether a type supports a given expression, member, or nested type.

Detection Idiomsince C++11

A template metaprogramming technique that uses SFINAE and partial specialization to determine at compile time whether a type satisfies a given constraint β€” a member function, a nested type, a valid expression β€” without producing a hard error.

Overview

The detection idiom answers the question: does type T support operation X? β€” at compile time, without committing to a call that might fail. It exploits SFINAE (Substitution Failure Is Not An Error): when deducing template arguments produces an ill-formed expression, that specialization is silently dropped rather than triggering a diagnostic. By pairing a primary template that derives from std::false_type with a partial specialization that derives from std::true_type and will only be selected when the expression is valid, you get a boolean trait.

The idiom matured across standards:

  • C++11 β€” decltype and std::declval made expression-validity checks practical.
  • C++17 β€” std::void_t canonicalized the two-template pattern into a single, well-understood idiom.
  • Library Fundamentals TS v2 β€” std::experimental::is_detected built a reusable abstraction on top of void_t.
  • C++20 β€” Concepts express the same constraints directly with far better error messages and should be preferred in new code.

The idiom remains relevant in C++11/14/17 codebases, backward-compatible library headers, and polyfill infrastructure.

Syntax

The canonical form relies on std::void_t (C++17):

cpp
// Primary template β€” wins when substitution fails
template<class T, class = void>
struct has_serialize : std::false_type {};

// Partial specialization β€” wins when the expression is well-formed
template<class T>
struct has_serialize<T,
    std::void_t<decltype(std::declval<T>().serialize(std::declval<std::ostream&>()))>
> : std::true_type {};

std::void_t<Exprs...> (C++17) maps any well-formed sequence of types to void. If any Expr is ill-formed, the partial specialization is not a candidate and the primary template wins. If all are well-formed, the partial specialization wins by being more specialised.

On C++11 and C++14, a compiler-bug workaround is required due to CWG 1558 β€” a defect that permitted compilers to ignore unused alias template parameters, making the naive template<typename...> using void_t = void; silently unreliable:

cpp
// C++11/14 workaround (CWG 1558)
template<typename... Ts>
struct make_void { using type = void; };

template<typename... Ts>
using void_t = typename make_void<Ts...>::type;

Examples

Detecting a member function

cpp
#include <type_traits>
#include <string>

template<class T, class = void>
struct has_to_string : std::false_type {};

template<class T>
struct has_to_string<T,
    std::void_t<decltype(std::declval<const T&>().to_string())>
> : std::true_type {};

// C++17: inline variable template for ergonomics
template<class T>
inline constexpr bool has_to_string_v = has_to_string<T>::value;

struct Widget { std::string to_string() const { return "Widget"; } };
struct Gadget {};

static_assert( has_to_string_v<Widget>);   // C++17
static_assert(!has_to_string_v<Gadget>);

Detecting a nested type

cpp
template<class T, class = void>
struct has_value_type : std::false_type {};

template<class T>
struct has_value_type<T, std::void_t<typename T::value_type>>
    : std::true_type {};

static_assert( has_value_type<std::vector<int>>::value);
static_assert(!has_value_type<double>::value);

The is_detected abstraction

Repeating the two-template boilerplate for every check is noise. The is_detected family (from Library Fundamentals TS v2, not in the IS) abstracts the mechanism into a reusable combinator:

cpp
struct nonesuch {
    ~nonesuch()                  = delete;
    nonesuch()                   = delete;
    nonesuch(nonesuch const&)    = delete;
    void operator=(nonesuch const&) = delete;
};

template<class Default, class AlwaysVoid,
         template<class...> class Op, class... Args>
struct detector {
    using value_t = std::false_type;
    using type    = Default;
};

template<class Default, template<class...> class Op, class... Args>
struct detector<Default, std::void_t<Op<Args...>>, Op, Args...> {  // C++17
    using value_t = std::true_type;
    using type    = Op<Args...>;
};

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

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

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

Each new trait then requires only an alias describing the expression β€” no boilerplate:

cpp
template<class T>
using serialize_expr = decltype(std::declval<T>().serialize(std::declval<std::ostream&>()));

template<class T>
using has_serialize = is_detected<serialize_expr, T>;

// Fallback type when not detected
template<class T>
using serialize_result_or_void = detected_or_t<void, serialize_expr, T>;

Compile-time dispatch with if constexpr

Detection becomes most useful when paired with branching. C++17's if constexpr collapses two overloads into a single function body:

cpp
template<class T>
void log_value(const T& v) {
    if constexpr (has_to_string_v<T>) {  // C++17
        std::cout << v.to_string() << '\n';
    } else {
        std::cout << "(no string representation)\n";
    }
}

Pre-C++17, the same branching required two std::enable_if-constrained overloads.

C++20: concepts supersede the idiom

cpp
// C++20
template<class T>
concept ToStringable = requires(const T& t) {
    { t.to_string() } -> std::convertible_to<std::string>;
};

template<ToStringable T>
void log_value(const T& v) { std::cout << v.to_string() << '\n'; }

template<class T>
void log_value(const T&) { std::cout << "(no string representation)\n"; }

Concepts produce actionable error messages, handle overload resolution cleanly, and do not require wrapping every check in a separate trait struct. Prefer them in C++20 and later; use the detection idiom only when cross-version compatibility matters.

Best Practices

Constrain the expression, not just the name. A member function with the right name but wrong return type satisfies a naive trait. When the return type matters, fold it into the detection expression:

cpp
template<class T>
using to_string_expr = std::enable_if_t<
    std::is_convertible_v<decltype(std::declval<const T&>().to_string()), std::string>
>;

Choose declval<T&>() vs declval<T>() deliberately. declval<T>() produces an rvalue; declval<T&>() produces an lvalue. A trait checking operator<< on a mutable stream must use lvalue references for both the stream and the object being tested.

Centralise detection machinery. Once nonesuch and detector are in a utilities header, every new trait is two lines. Avoid repeating the two-template pattern across dozens of traits.

Expose _v variable templates. Trait structs with ::value are verbose at call sites. Provide inline constexpr bool has_X_v = has_X<T>::value; (C++17) for every trait you ship.

Common Pitfalls

Hard errors inside function bodies are not SFINAE. SFINAE applies only in the immediate context of template argument deduction β€” chiefly in function signatures and class template specializations. A substitution failure inside a function body is a hard error. std::void_t only protects expressions that appear directly in the specialization's template parameter list.

Forgetting std::declval. decltype(T().member()) fails for non-default-constructible types. Always write decltype(std::declval<T>().member()).

Shadowing with const correctness. std::declval<T>() produces T&&. If you want to test a const-qualified member, use std::declval<const T&>() β€” otherwise you may detect a mutable overload and miss that the const one is absent.

CWG 1558 on older toolchains. GCC before 5 and Clang before 3.6 may silently accept a trivial void_t alias and then ignore it during specialization matching. Use the make_void indirection if supporting pre-C++17 compilers.

See Also

  • std::void_t β€” C++17 standard utility that canonicalized this pattern
  • std::experimental::is_detected β€” Library Fundamentals TS v2 reusable abstraction
  • std::enable_if β€” C++11 building block for conditional overload sets
  • C++20 requires expressions β€” the modern, concept-native successor to expression-validity detection