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

Template Metaprogramming: SFINAE, Type Traits, and Concepts

C++ TMP from SFINAE and enable_if through void_t and the detection idiom to C++20 Concepts: how to constrain templates and write compile-time type computations.

Template metaprogramming (TMP) is the technique of computing types and values at compile time using C++ templates. It ranges from simple type traits (std::is_integral<T>) to full-blown compile-time conditionals and type lists. This page covers the evolution: SFINAE β†’ void_t β†’ if constexpr β†’ C++20 Concepts.


Why TMP exists

Templates let you write code that works for many types. But sometimes a template should only work for some types β€” and the constraint should be checked at compile time, producing a clear error, not a cryptic instantiation failure deep inside the implementation.

cpp
// BAD: no constraint β€” error occurs inside sort() if T doesn't support <
template<typename T>
void sort(std::vector<T>& v);

// GOOD: constraint at the declaration β€” clear error at call site
template<std::totally_ordered T>
void sort(std::vector<T>& v);

TMP provides the machinery to express and check these constraints, and to branch on type properties at compile time.


SFINAE: Substitution Failure Is Not An Error

When the compiler substitutes template arguments and the resulting type/expression is ill-formed, that candidate is silently removed from the overload set rather than causing a hard error. This is SFINAE β€” and it is the foundation of C++11/14 type-based dispatch.

cpp
// Two overloads: one for integral types, one for floating-point
template<typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
double_it(T x) { return x * 2; }

template<typename T>
typename std::enable_if<std::is_floating_point<T>::value, T>::type
double_it(T x) { return x * 2.0; }

double_it(3);     // selects integral overload
double_it(3.0);   // selects floating-point overload
double_it("hi");  // compile error: no matching function β€” both SFINAEd out

When T = int, std::is_integral<int>::value is true, so std::enable_if<true, int>::type = int. The integral overload survives. When T = int, std::is_floating_point<int>::value is false, so std::enable_if<false, int>::type has no member type β€” substitution failure, the floating-point overload is silently dropped.


std::enable_if and enable_if_t

std::enable_if<Cond, T> is a struct with a member type = T when Cond is true, and no member type when Cond is false. C++14 adds enable_if_t<Cond, T> as a shorthand.

cpp
// enable_if as return type (C++11 style):
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
safe_negate(T x) {
    return (x == std::numeric_limits<T>::min()) ? x : -x;
}

// enable_if as a defaulted template parameter (more composable):
template<typename T,
         std::enable_if_t<std::is_integral_v<T>, int> = 0>
T safe_negate(T x) {
    return (x == std::numeric_limits<T>::min()) ? x : -x;
}

// enable_if on a constructor (return type doesn't exist):
template<typename U,
         std::enable_if_t<std::is_convertible_v<U, T>, int> = 0>
MyContainer(U&& source) { /* ... */ }

Common pitfall: both overloads in a set must use enable_if to be mutually exclusive. Forgetting to constrain one of them causes an ambiguity error.


Type traits from scratch

The standard library type traits (<type_traits>) are built from a handful of primitives. Understanding them helps you write your own.

is_same

cpp
template<typename T, typename U>
struct is_same : std::false_type {};

template<typename T>
struct is_same<T, T> : std::true_type {};

static_assert(is_same<int, int>::value);
static_assert(!is_same<int, float>::value);

std::true_type is std::integral_constant<bool, true>; std::false_type is std::integral_constant<bool, false>. They provide ::value, operator bool, and a type typedef.

remove_reference

cpp
template<typename T> struct remove_reference      { using type = T; };
template<typename T> struct remove_reference<T&>  { using type = T; };
template<typename T> struct remove_reference<T&&> { using type = T; };

template<typename T>
using remove_reference_t = typename remove_reference<T>::type;

This is how partial specialization drives most type trait computations: the primary template handles the general case; specializations peel off qualifiers.

conditional

cpp
template<bool B, typename T, typename F>
struct conditional { using type = T; };

template<typename T, typename F>
struct conditional<false, T, F> { using type = F; };

template<bool B, typename T, typename F>
using conditional_t = typename conditional<B, T, F>::type;

// Usage:
using StorageType = conditional_t<sizeof(T) <= 8, T, T*>;

void_t: detecting the existence of a member or expression

void_t<...> is a C++17 utility that maps any well-formed sequence of types to void. Its power comes from being used in SFINAE contexts: if a type expression is ill-formed, the substitution fails.

cpp
template<typename...> using void_t = void;

Checking for a member function

cpp
// Primary template: "does T have a member .size()?" β€” default no
template<typename T, typename = void>
struct has_size : std::false_type {};

// Specialization: fires when T::size() compiles
template<typename T>
struct has_size<T, std::void_t<decltype(std::declval<T>().size())>>
    : std::true_type {};

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

When T = std::vector<int>: std::declval<T>().size() is std::size_t, so void_t<std::size_t> = void. The partial specialization matches. When T = int: std::declval<int>().size() is ill-formed (int has no size()). SFINAE drops the partial specialization; the primary template (false_type) survives.

The detection idiom (Library Fundamentals TS v2 pattern)

cpp
// Detector boilerplate (write once):
template<typename Default, typename AlwaysVoid,
         template<typename...> class Op, typename... Args>
struct detector {
    using value_t = std::false_type;
    using type    = Default;
};

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:

cpp
// Define "operations" as alias templates:
template<typename T> using has_serialize_t    = decltype(std::declval<T>().serialize());
template<typename T> using has_begin_end_t    = decltype(std::begin(std::declval<T>()));
template<typename T> using supports_plus_t    = decltype(std::declval<T>() + std::declval<T>());

// Detect:
static_assert( is_detected<has_serialize_t, MyClass>::value);
static_assert(!is_detected<has_serialize_t, int>::value);
static_assert( is_detected<has_begin_end_t, std::vector<int>>::value);

// Get the detected type (nonesuch if not detected):
using SerializeResult = detected_t<has_serialize_t, MyClass>;

This idiom scales to detecting any expression β€” member functions, free functions, operators, nested types β€” without writing a new has_X struct for each one.


Fold expressions (C++17)

Fold expressions evaluate a binary operator over a parameter pack, replacing the recursive TMP pattern for pack accumulation:

cpp
// C++11: recursive accumulation
template<typename T> T sum(T x) { return x; }
template<typename T, typename... Ts>
T sum(T first, Ts... rest) { return first + sum(rest...); }

// C++17: fold expression
template<typename... Ts>
auto sum(Ts... args) {
    return (args + ...);   // unary right fold: a + (b + (c + ...))
}

sum(1, 2, 3, 4);   // 10

Four fold forms:

cpp
(pack op ...)       // unary right fold:  (a op (b op c))
(... op pack)       // unary left fold:   ((a op b) op c)
(pack op ... op I)  // binary right fold: (a op (b op (c op I)))
(I op ... op pack)  // binary left fold:  (((I op a) op b) op c)
cpp
// Print all args:
template<typename... Ts>
void print_all(Ts&&... args) {
    ((std::cout << args << ' '), ...);   // comma fold
}

// All-of check:
template<typename T, typename... Ts>
bool all_same_type() {
    return (std::is_same_v<T, Ts> && ...);
}

// Logical OR over a pack:
template<typename T, typename... Ts>
bool is_any_of() {
    return (std::is_same_v<T, Ts> || ...);
}

if constexpr: branch at compile time (C++17)

if constexpr eliminates the need for SFINAE in many cases by allowing a function body to branch on a compile-time condition, with only the taken branch instantiated:

cpp
// SFINAE version (C++11/14):
template<typename T>
std::enable_if_t<std::is_integral_v<T>, std::string>
stringify(T x) { return std::to_string(x); }

template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, std::string>
stringify(T x) { return std::to_string(x); }

// if constexpr version (C++17) β€” one function, no overloads:
template<typename T>
std::string stringify(T x) {
    if constexpr (std::is_integral_v<T>) {
        return std::to_string(x);
    } else if constexpr (std::is_floating_point_v<T>) {
        return std::to_string(x);
    } else {
        static_assert(std::is_same_v<T, std::string>,
                      "stringify: unsupported type");
        return x;
    }
}

The non-taken branch is not instantiated β€” so it can contain code that would be ill-formed for the given type. This is the key: SFINAE works at overload resolution; if constexpr works inside a function body.

cpp
// Recursive template algorithm with if constexpr (C++17):
template<std::size_t N, typename Tuple>
void print_tuple(const Tuple& t) {
    if constexpr (N < std::tuple_size_v<Tuple>) {
        std::cout << std::get<N>(t) << ' ';
        print_tuple<N+1>(t);   // only instantiated if N < size
    }
}

print_tuple<0>(std::make_tuple(1, "hello", 3.14));
// Prints: 1 hello 3.14

C++20 Concepts: the readable replacement

C++20 Concepts replace most SFINAE patterns with readable, composable constraints. The same ideas remain β€” you're still checking type properties at compile time β€” but the syntax is direct rather than indirect.

cpp
// C++11 SFINAE:
template<typename T>
std::enable_if_t<std::is_integral_v<T> &&
                 std::is_signed_v<T>, void>
process(T x);

// C++20 Concepts:
template<typename T>
concept SignedInteger = std::integral<T> && std::signed_integral<T>;

void process(SignedInteger auto x);
// or: template<SignedInteger T> void process(T x);

When to still use SFINAE/void_t:

  • Targeting C++17 or earlier
  • Detecting whether an expression is valid (SFINAE and void_t still express this more directly than requires in some cases)
  • Partial specialization selection (Concepts don't apply to partial specs directly)

Migration path:

  1. Replace enable_if return types β†’ requires clause on the function
  2. Replace void_t detection traits β†’ requires { expr; } in a concept
  3. Replace if constexpr (is_X<T>::value) β†’ if constexpr (X<T>) (concept as bool)
  4. Replace overload sets with SFINAE β†’ overloads constrained by concepts (concept subsumption handles priority)

Quick reference: TMP techniques

GoalC++11/14C++17C++20
Constrain a functionenable_if_t<Cond, RetType>enable_if_t or if constexprrequires Cond / concept
Detect member/expressionvoid_t<decltype(...)>void_t or detection idiomrequires { expr; } in concept
Branch in function bodyOverload + SFINAEif constexprif constexpr (unchanged)
Type computationPartial specializationPartial specialization + void_tPartial specialization + concept
Accumulate over packRecursive templateFold expression (args op ...)Fold expression (unchanged)
Select type at compile timeconditional_t<B, T, F>conditional_tconditional_t or concept-based overload

Pitfalls

SFINAE only works in immediate context. An error inside a function body (not in a template parameter or return type) is a hard error, not a SFINAE opportunity.

cpp
// WRONG: error is inside the body, not in the declaration context
template<typename T>
void bad_sfinae(T x) {
    typename T::value_type v = x.front();  // hard error if T has no value_type
}

// RIGHT: put the constraint in the declaration
template<typename T>
    requires requires { typename T::value_type; { std::declval<T>().front() }; }
void good(T x) {
    typename T::value_type v = x.front();
}

enable_if on both branches must be mutually exclusive. If both conditions can be true simultaneously, you get an ambiguity, not SFINAE selection.

Fold expressions need at least one element (for unary folds) β€” a zero-element pack is only valid for && (gives true), || (gives false), and , (gives void). Other operators on empty packs are ill-formed.