AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

12 Templates and generic programming / Lesson 6 of 6

Type traits and metaprogramming — computing with types

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

What are type traits in C++?

Type traits are class templates in <type_traits> that answer questions about a type at compile time or build a new type from it. Predicates such as std::is_integral_v<T> are bool constants; transformers such as std::remove_cvref_t<T> and std::conditional_t<B, T, F> produce types. They are built by specialisation and drive if constexpr dispatch, forwarding references and generic containers.

A type trait is a class template that answers a question about a type at compile time — is it an integer, is it a pointer, what is it once the const and the & are stripped — or that manufactures a new type from an old one. <type_traits> holds a hundred of them, every one built from the specialisation machinery of lesson 3, and they are what makes if constexpr dispatch, forwarding references and generic containers work. This lesson covers the traits you will actually use, decltype and std::declval for naming the type of an expression, if constexpr as the dispatch tool, SFINAE and std::enable_if as the pre-C++20 technique you must be able to read, and why a constexpr function beats template recursion for computing values.

The predicates

#include <stdexcept>
#include <string>
#include <type_traits>

static_assert(std::is_integral_v<int>);
static_assert(std::is_integral_v<bool>);            // bool is an integer type to the language
static_assert(std::is_floating_point_v<double>);
static_assert(std::is_same_v<int, signed int>);
static_assert(!std::is_same_v<int, long>);          // different types even where both are 32 bits
static_assert(std::is_pointer_v<const char*>);
static_assert(std::is_class_v<std::string>);
static_assert(std::is_base_of_v<std::exception, std::runtime_error>);
static_assert(std::is_convertible_v<int, double>);

Each std::is_x<T> is a struct with a static constexpr bool value, and std::is_x_v<T> is the C++17 variable template that saves writing ::value. The implementation is lesson 3's trait pattern: a primary template deriving from std::false_type, specialisations for the matching types deriving from std::true_type, and a partial specialisation is_pointer<T*> for the pointer family. Two more you will meet: std::is_arithmetic_v (integral or floating), and std::is_nothrow_move_constructible_v, which std::vector consults before deciding whether to move or copy elements when it grows (Module 9).

The transformers

Traits that produce a type expose it as ::type, with a _t alias:

Traitint becomesUsed for
std::remove_cvref_t<const int&>intNaming the plain type behind a forwarding reference
std::remove_pointer_t<int*>intPointee of a pointer parameter
std::add_pointer_t<int>int*Building a pointer type
std::make_unsigned_t<int>unsigned intBit manipulation on a signed input
std::decay_t<const char(&)[6]>const char*What a by-value parameter would receive
std::conditional_t<B, T, F>T if B else FChoosing a type by a compile-time flag
std::common_type_t<int, double>doubleThe type ?: would produce
std::underlying_type_t<E>the enum's integer typePrinting an enum class

std::conditional_t is a type-level ?: and the everyday way to pick a representation:

template <typename T>
using Accumulator = std::conditional_t<std::is_integral_v<T>, long long, double>;

template <typename T>
Accumulator<T> total(const std::vector<T>& v) {
    Accumulator<T> sum{};
    for (const T& x : v) sum += x;
    return sum;
}

total on a std::vector<int> sums into a long long and cannot overflow on a few thousand values; on a std::vector<float> it sums into a double. Both branch types must be valid — conditional_t selects, it does not discard — so it cannot choose between "a type that exists" and "one that does not".

remove_cvref_t earns its place with forwarding references. template <typename T> void f(T&& x) deduces T as std::string& for an lvalue std::string, so std::is_same_v<T, std::string> is false for the very argument you meant to catch. Strip first: using U = std::remove_cvref_t<T>; then test U. The same applies to auto&& in a range-for.

decltype and std::declval

decltype(expr) is the type of an expression, computed without evaluating it:

int i = 0;
decltype(i) j = 1;                 // int
decltype((i)) r = i;               // int& — an extra pair of parentheses makes it an lvalue expression

template <typename A, typename B>
auto add(const A& a, const B& b) -> decltype(a + b) { return a + b; }

template <typename C>
using SizeOf = decltype(std::declval<C>().size());     // the type size() returns, for any C that has one

std::declval<T>() pretends to produce a T inside an unevaluated context — decltype, sizeof, a requires expression — so you can ask what an expression would be without needing an object, or even a constructor. It has no usable definition: calling it outside an unevaluated context is ill-formed, and the standard libraries make that a compile-time error. The decltype(i) versus decltype((i)) distinction is a well-known interview question: a name gives the declared type, a parenthesised name is an lvalue expression and gives a reference.

if constexpr dispatch

Where lesson 5 used if constexpr with a concept, the traits version reads the same and predates it:

template <typename T>
void describe(const T& v) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "integral " << v << (v % 2 == 0 ? " even" : " odd") << '\n';
    } else if constexpr (std::is_floating_point_v<T>) {
        std::cout << "floating " << std::llround(v) << '\n';
    } else if constexpr (std::is_same_v<T, std::string>) {
        std::cout << "text of " << v.size() << " chars\n";
    } else {
        std::cout << "other\n";
    }
}

Only the branch whose condition is true is instantiated for a given T; v % 2 is never compiled for a double and v.size() never for an int. To make an unsupported type a compile error rather than an "other" line, the last branch can static_assert — but static_assert(false) inside a template is ill-formed on its own (the compiler may reject it before any instantiation), so the idiom is a dependent false: template <typename> inline constexpr bool always_false = false; and then static_assert(always_false<T>, "unsupported type"). C++23 relaxes this, but the judge is C++20.

SFINAE and enable_if — the old way

Before concepts, a template was switched on and off with a trick called substitution failure is not an error: when substituting a deduced T into a candidate's signature produces an invalid type, that candidate is dropped silently instead of ending compilation.

template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> half(T v) { return v / 2; }

template <typename T>
std::enable_if_t<std::is_floating_point_v<T>, T> half(T v) { return v / 2; }

std::enable_if_t<false, T> names a ::type that does not exist, so for half(2.5) the first candidate fails substitution and vanishes, leaving the second. The technique ran a decade of library code and every error it produced was a list of "candidate template ignored: substitution failure" notes. You will read it in older codebases and in the standard library's own headers; you should write the concept version from lesson 5, which says the same thing in the signature and produces a message that names the unmet requirement.

Compile-time recursion versus constexpr

The original template metaprogramming computed values by recursion and specialisation:

template <unsigned N>
struct Factorial { static constexpr unsigned long long value = N * Factorial<N - 1>::value; };
template <>
struct Factorial<0> { static constexpr unsigned long long value = 1; };

static_assert(Factorial<10>::value == 3628800);

constexpr unsigned long long factorial(unsigned n) {      // the modern form
    unsigned long long result = 1;
    for (unsigned i = 2; i <= n; ++i) result *= i;
    return result;
}
static_assert(factorial(10) == 3628800);

The template version instantiates eleven classes to compute one number, cannot use a loop, and reports mistakes as a chain of instantiation notes. The constexpr function is ordinary C++ evaluated at compile time when its arguments are constants — C++20 allows loops, std::vector and std::string inside — and Module 16 covers it in depth. The rule that survives: compute values with constexpr functions, compute types with traits.

Pitfalls

MistakeWhat happens
std::is_same_v<T, std::string> with T&& parameterT is std::string&: false. Strip with remove_cvref_t.
Testing std::is_integral_v<T> to mean "a number to print"bool and char are integral too.
std::conditional_t<B, T, F> with an invalid FBoth branches must be valid types.
static_assert(false) in the last if constexpr branchIll-formed before C++23; use a dependent false.
Plain if where if constexpr was neededEvery branch compiles for every T: v.size() on an int fails.
decltype((x)) when decltype(x) was meantA reference instead of the declared type.
Calling std::declval<T>() outside decltypeUndefined symbol at link time.

Key takeaways

  • is_x_v<T> predicates and x_t<T> transformers are the two halves of <type_traits>; both are traits built by specialisation.
  • conditional_t picks a type from a flag; remove_cvref_t recovers the plain type behind a forwarding reference.
  • decltype names an expression's type unevaluated; std::declval<T>() supplies a fake T for it.
  • if constexpr instantiates only the taken branch — the dispatch tool; end with a dependent-false static_assert when the rest must not compile.
  • SFINAE and enable_if are the pre-C++20 constraint; read them, write concepts.
  • Compute values with constexpr functions, types with traits; recursive template arithmetic is history.

Common questions

What is SFINAE in C++?

SFINAE stands for substitution failure is not an error: when substituting deduced template arguments into a candidate's signature produces an invalid type, that candidate is silently dropped from overload resolution instead of ending compilation. std::enable_if_t<condition, T> exploits it to switch overloads on and off. It is the pre-C++20 technique; concepts say the same thing more clearly.

What is the difference between decltype(x) and decltype((x))?

decltype(x) on a plain variable name gives its declared type, such as int. Extra parentheses make it an lvalue expression, and decltype of an lvalue expression is a reference, so decltype((x)) is int&. It is a well-known interview question and a common source of unexpected references.

What does std::declval do?

std::declval<T>() pretends to produce a value of type T inside an unevaluated context such as decltype, sizeof or a requires expression, so you can ask what an expression would return without an object or a usable constructor: decltype(std::declval<C>().size()). It has no definition, so using it in evaluated code is an error.

Why is std::is_same_v<T, std::string> false inside f(T&& x)?

For an lvalue std::string argument, the forwarding reference deduces T as std::string&, which is not the same type as std::string. Strip the reference and cv-qualifiers first — std::is_same_v<std::remove_cvref_t<T>, std::string> — and the test works for lvalues and rvalues alike.

Should I use constexpr functions or template metaprogramming?

Compute values with constexpr functions and types with traits. The old recursive style — Factorial<N> defined through Factorial<N - 1> plus a specialisation for 0 — instantiates a class per step and cannot use a loop, while a constexpr function is ordinary C++ with loops that the compiler evaluates when its arguments are constants.

Mark as readSign in to keep your place.

C++ Type Traits Explained: SFINAE, decltype and if constexpr

C++ type traits answer compile-time questions about types and build new ones. The key traits, decltype and declval, if constexpr dispatch and reading SFINAE.

  • Course: C++ study plan
  • Module: Templates and generic programming
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: Clang 18 · C++20

What are type traits in C++?

Type traits are class templates in <type_traits> that answer questions about a type at compile time or build a new type from it. Predicates such as std::is_integral_v<T> are bool constants; transformers such as std::remove_cvref_t<T> and std::conditional_t<B, T, F> produce types. They are built by specialisation and drive if constexpr dispatch, forwarding references and generic containers.

Lesson

A type trait is a class template that answers a question about a type at compile time — is it an integer, is it a pointer, what is it once the const and the & are stripped — or that manufactures a new type from an old one. <type_traits> holds a hundred of them, every one built from the specialisation machinery of lesson 3, and they are what makes if constexpr dispatch, forwarding references and generic containers work. This lesson covers the traits you will actually use, decltype and std::declval for naming the type of an expression, if constexpr as the dispatch tool, SFINAE and std::enable_if as the pre-C++20 technique you must be able to read, and why a constexpr function beats template recursion for computing values.

The predicates

#include <stdexcept>
#include <string>
#include <type_traits>

static_assert(std::is_integral_v<int>);
static_assert(std::is_integral_v<bool>);            // bool is an integer type to the language
static_assert(std::is_floating_point_v<double>);
static_assert(std::is_same_v<int, signed int>);
static_assert(!std::is_same_v<int, long>);          // different types even where both are 32 bits
static_assert(std::is_pointer_v<const char*>);
static_assert(std::is_class_v<std::string>);
static_assert(std::is_base_of_v<std::exception, std::runtime_error>);
static_assert(std::is_convertible_v<int, double>);

Each std::is_x<T> is a struct with a static constexpr bool value, and std::is_x_v<T> is the C++17 variable template that saves writing ::value. The implementation is lesson 3's trait pattern: a primary template deriving from std::false_type, specialisations for the matching types deriving from std::true_type, and a partial specialisation is_pointer<T*> for the pointer family. Two more you will meet: std::is_arithmetic_v (integral or floating), and std::is_nothrow_move_constructible_v, which std::vector consults before deciding whether to move or copy elements when it grows (Module 9).

The transformers

Traits that produce a type expose it as ::type, with a _t alias:

Traitint becomesUsed for
std::remove_cvref_t<const int&>intNaming the plain type behind a forwarding reference
std::remove_pointer_t<int*>intPointee of a pointer parameter
std::add_pointer_t<int>int*Building a pointer type
std::make_unsigned_t<int>unsigned intBit manipulation on a signed input
std::decay_t<const char(&)[6]>const char*What a by-value parameter would receive
std::conditional_t<B, T, F>T if B else FChoosing a type by a compile-time flag
std::common_type_t<int, double>doubleThe type ?: would produce
std::underlying_type_t<E>the enum's integer typePrinting an enum class

std::conditional_t is a type-level ?: and the everyday way to pick a representation:

template <typename T>
using Accumulator = std::conditional_t<std::is_integral_v<T>, long long, double>;

template <typename T>
Accumulator<T> total(const std::vector<T>& v) {
    Accumulator<T> sum{};
    for (const T& x : v) sum += x;
    return sum;
}

total on a std::vector<int> sums into a long long and cannot overflow on a few thousand values; on a std::vector<float> it sums into a double. Both branch types must be valid — conditional_t selects, it does not discard — so it cannot choose between "a type that exists" and "one that does not".

remove_cvref_t earns its place with forwarding references. template <typename T> void f(T&& x) deduces T as std::string& for an lvalue std::string, so std::is_same_v<T, std::string> is false for the very argument you meant to catch. Strip first: using U = std::remove_cvref_t<T>; then test U. The same applies to auto&& in a range-for.

decltype and std::declval

decltype(expr) is the type of an expression, computed without evaluating it:

int i = 0;
decltype(i) j = 1;                 // int
decltype((i)) r = i;               // int& — an extra pair of parentheses makes it an lvalue expression

template <typename A, typename B>
auto add(const A& a, const B& b) -> decltype(a + b) { return a + b; }

template <typename C>
using SizeOf = decltype(std::declval<C>().size());     // the type size() returns, for any C that has one

std::declval<T>() pretends to produce a T inside an unevaluated context — decltype, sizeof, a requires expression — so you can ask what an expression would be without needing an object, or even a constructor. It has no usable definition: calling it outside an unevaluated context is ill-formed, and the standard libraries make that a compile-time error. The decltype(i) versus decltype((i)) distinction is a well-known interview question: a name gives the declared type, a parenthesised name is an lvalue expression and gives a reference.

if constexpr dispatch

Where lesson 5 used if constexpr with a concept, the traits version reads the same and predates it:

template <typename T>
void describe(const T& v) {
    if constexpr (std::is_integral_v<T>) {
        std::cout << "integral " << v << (v % 2 == 0 ? " even" : " odd") << '\n';
    } else if constexpr (std::is_floating_point_v<T>) {
        std::cout << "floating " << std::llround(v) << '\n';
    } else if constexpr (std::is_same_v<T, std::string>) {
        std::cout << "text of " << v.size() << " chars\n";
    } else {
        std::cout << "other\n";
    }
}

Only the branch whose condition is true is instantiated for a given T; v % 2 is never compiled for a double and v.size() never for an int. To make an unsupported type a compile error rather than an "other" line, the last branch can static_assert — but static_assert(false) inside a template is ill-formed on its own (the compiler may reject it before any instantiation), so the idiom is a dependent false: template <typename> inline constexpr bool always_false = false; and then static_assert(always_false<T>, "unsupported type"). C++23 relaxes this, but the judge is C++20.

SFINAE and enable_if — the old way

Before concepts, a template was switched on and off with a trick called substitution failure is not an error: when substituting a deduced T into a candidate's signature produces an invalid type, that candidate is dropped silently instead of ending compilation.

template <typename T>
std::enable_if_t<std::is_integral_v<T>, T> half(T v) { return v / 2; }

template <typename T>
std::enable_if_t<std::is_floating_point_v<T>, T> half(T v) { return v / 2; }

std::enable_if_t<false, T> names a ::type that does not exist, so for half(2.5) the first candidate fails substitution and vanishes, leaving the second. The technique ran a decade of library code and every error it produced was a list of "candidate template ignored: substitution failure" notes. You will read it in older codebases and in the standard library's own headers; you should write the concept version from lesson 5, which says the same thing in the signature and produces a message that names the unmet requirement.

Compile-time recursion versus constexpr

The original template metaprogramming computed values by recursion and specialisation:

template <unsigned N>
struct Factorial { static constexpr unsigned long long value = N * Factorial<N - 1>::value; };
template <>
struct Factorial<0> { static constexpr unsigned long long value = 1; };

static_assert(Factorial<10>::value == 3628800);

constexpr unsigned long long factorial(unsigned n) {      // the modern form
    unsigned long long result = 1;
    for (unsigned i = 2; i <= n; ++i) result *= i;
    return result;
}
static_assert(factorial(10) == 3628800);

The template version instantiates eleven classes to compute one number, cannot use a loop, and reports mistakes as a chain of instantiation notes. The constexpr function is ordinary C++ evaluated at compile time when its arguments are constants — C++20 allows loops, std::vector and std::string inside — and Module 16 covers it in depth. The rule that survives: compute values with constexpr functions, compute types with traits.

Pitfalls

MistakeWhat happens
std::is_same_v<T, std::string> with T&& parameterT is std::string&: false. Strip with remove_cvref_t.
Testing std::is_integral_v<T> to mean "a number to print"bool and char are integral too.
std::conditional_t<B, T, F> with an invalid FBoth branches must be valid types.
static_assert(false) in the last if constexpr branchIll-formed before C++23; use a dependent false.
Plain if where if constexpr was neededEvery branch compiles for every T: v.size() on an int fails.
decltype((x)) when decltype(x) was meantA reference instead of the declared type.
Calling std::declval<T>() outside decltypeUndefined symbol at link time.

Key takeaways

  • is_x_v<T> predicates and x_t<T> transformers are the two halves of <type_traits>; both are traits built by specialisation.
  • conditional_t picks a type from a flag; remove_cvref_t recovers the plain type behind a forwarding reference.
  • decltype names an expression's type unevaluated; std::declval<T>() supplies a fake T for it.
  • if constexpr instantiates only the taken branch — the dispatch tool; end with a dependent-false static_assert when the rest must not compile.
  • SFINAE and enable_if are the pre-C++20 constraint; read them, write concepts.
  • Compute values with constexpr functions, types with traits; recursive template arithmetic is history.

Common questions

What is SFINAE in C++?

SFINAE stands for substitution failure is not an error: when substituting deduced template arguments into a candidate's signature produces an invalid type, that candidate is silently dropped from overload resolution instead of ending compilation. std::enable_if_t<condition, T> exploits it to switch overloads on and off. It is the pre-C++20 technique; concepts say the same thing more clearly.

What is the difference between decltype(x) and decltype((x))?

decltype(x) on a plain variable name gives its declared type, such as int. Extra parentheses make it an lvalue expression, and decltype of an lvalue expression is a reference, so decltype((x)) is int&. It is a well-known interview question and a common source of unexpected references.

What does std::declval do?

std::declval<T>() pretends to produce a value of type T inside an unevaluated context such as decltype, sizeof or a requires expression, so you can ask what an expression would return without an object or a usable constructor: decltype(std::declval<C>().size()). It has no definition, so using it in evaluated code is an error.

Why is std::is_same_v<T, std::string> false inside f(T&& x)?

For an lvalue std::string argument, the forwarding reference deduces T as std::string&, which is not the same type as std::string. Strip the reference and cv-qualifiers first — std::is_same_v<std::remove_cvref_t<T>, std::string> — and the test works for lvalues and rvalues alike.

Should I use constexpr functions or template metaprogramming?

Compute values with constexpr functions and types with traits. The old recursive style — Factorial<N> defined through Factorial<N - 1> plus a specialisation for 0 — instantiates a class per step and cannot use a loop, while a constexpr function is ordinary C++ with loops that the compiler evaluates when its arguments are constants.

Exercises

An if constexpr dispatcher over a forwarding reference

Write template <typename T> void describe(T&& v) — a forwarding reference, so that for an lvalue std::string the parameter T deduces as std::string&. Strip it first with using U = std::remove_cvref_t<T>; and dispatch on U with if constexpr, in this order: std::is_same_v<U, bool> prints bool true or bool false; std::is_integral_v<U> prints integral <v> even or integral <v> odd; std::is_floating_point_v<U> prints floating <std::llround(v)>; std::is_same_v<U, std::string> prints text of <size> chars; anything else prints other. The bool test must come before the integral one, because bool is integral.

Read n, then n lines of <kind> <value>: int, double, string, bool (0/1), char (one character — it is integral too, and prints as a character), and pair <a> <b> (a std::pair<int, int>, which is other). Read each value into a variable and call describe(variable) on it.

Input: n, then n lines. Output: one line per input line.

6
int 42
double 2.5
string hello
bool 1
char x
pair 1 2

prints

integral 42 even
floating 3
text of 5 chars
bool true
integral x even
other

Pick the accumulator with std::conditional_t

Write an alias template Accumulator<T> = std::conditional_t<std::is_integral_v<T>, long long, double> and a function template total(const std::vector<T>&) that returns Accumulator<T> — the sum of the elements in the wider type. Print each result as <kind>: sum=<sum> accumulator=<long long|double>, deciding the label with std::is_same_v<Accumulator<T>, long long> in an if constexpr; a double sum has two decimals.

Read n, then n lines of <kind> <k> <v1> … <vk> where <kind> is int, char (single characters, summed by character code), float or double. k may be 0.

Input: n, then n lines. Output: one line per input line.

4
int 2 2000000000 2000000000
char 3 a b c
float 2 0.5 0.25
double 3 1.5 2.5 3

prints

int: sum=4000000000 accumulator=long long
char: sum=294 accumulator=long long
float: sum=0.75 accumulator=double
double: sum=7.00 accumulator=double

In this module: Templates and generic programming

← Concepts and constraints — saying what T must be · Checkpoint — Templates and generic programming →