How <type_traits> Works: Type Queries, enable_if, void_t Detection and if constexpr
Key takeaways
C++ type traits: is_integral, remove_reference, SFINAE with enable_if, void_t, and compile-time branches with if constexpr.
What are type traits?
Type traits are compile-time utilities that query and transform types. They enable metaprogramming and generic code that adapts to different types.
Mechanically, most type traits are just template class specializations that inherit from std::true_type or std::false_type and expose a ::value static constant — there’s no compiler magic involved for the basic ones. is_integral<int> is a specialization the standard library ships that happens to be true for int, char, bool, and friends. The _v suffix (is_integral_v<T>) is just a variable template shorthand for is_integral<T>::value, and the _t suffix on transformation traits (remove_reference_t<T>) is shorthand for typename remove_reference<T>::type — both exist purely to cut down on boilerplate at call sites, added in C++14 and C++17 respectively.
#include <type_traits>
#include <iostream>
template<typename T>
void process(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "Processing integer: " << value << "\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "Processing float: " << value << "\n";
} else {
std::cout << "Processing other type\n";
}
}
int main() {
process(42); // "Processing integer: 42"
process(3.14); // "Processing float: 3.14"
process("text"); // "Processing other type"
}
Basic type queries
These “primary” categories are mutually exclusive and collectively exhaustive — every type falls into exactly one of them (void, null pointer, integral, floating-point, array, pointer, reference, member pointer, enum, union, class, or function). The “composite” categories below are built by combining primary ones with std::disjunction-style logic, which is why, for instance, is_arithmetic is true for both integral and floating-point types.
Primary type categories
#include <type_traits>
// Integral types
static_assert(std::is_integral_v<int>);
static_assert(std::is_integral_v<char>);
static_assert(std::is_integral_v<bool>);
static_assert(!std::is_integral_v<float>);
// Floating-point types
static_assert(std::is_floating_point_v<float>);
static_assert(std::is_floating_point_v<double>);
static_assert(!std::is_floating_point_v<int>);
// Pointer types
static_assert(std::is_pointer_v<int*>);
static_assert(std::is_pointer_v<char*>);
static_assert(!std::is_pointer_v<int>);
// Array types
static_assert(std::is_array_v<int[10]>);
static_assert(!std::is_array_v<int*>);
// Reference types
static_assert(std::is_reference_v<int&>);
static_assert(std::is_reference_v<int&&>);
static_assert(!std::is_reference_v<int>);
Composite type categories
Note that is_object_v<int&> is false — references are explicitly excluded from the “object” category in the standard, because a reference isn’t itself a storage location; it’s an alias for another object’s storage. This distinction bites people writing generic containers: code that assumes “if it’s not a function or reference, it’s an object I can store” needs to check is_object rather than just negating is_reference, since void and function types are neither objects nor references either.
// Arithmetic (integral or floating-point)
static_assert(std::is_arithmetic_v<int>);
static_assert(std::is_arithmetic_v<double>);
static_assert(!std::is_arithmetic_v<std::string>);
// Scalar (arithmetic, pointer, enum, nullptr_t)
static_assert(std::is_scalar_v<int>);
static_assert(std::is_scalar_v<int*>);
static_assert(!std::is_scalar_v<std::vector<int>>);
// Object types
static_assert(std::is_object_v<int>);
static_assert(std::is_object_v<std::string>);
static_assert(!std::is_object_v<int&>);
Type transformations
Transformation traits don’t touch types — they compute a new type via a nested ::type member and leave the original alone. This is why remove_const_t<const int*> is still const int*: the pointer itself isn’t const, only the int it points to is, so remove_const correctly leaves the pointee qualifier untouched — it only strips a top-level const/volatile on the type you hand it directly, not qualifiers buried inside it.
Remove qualifiers
#include <type_traits>
// Remove const
using T1 = std::remove_const_t<const int>; // int
using T2 = std::remove_const_t<const int*>; // const int* (pointer itself not const)
// Remove volatile
using T3 = std::remove_volatile_t<volatile int>; // int
// Remove cv (const and volatile)
using T4 = std::remove_cv_t<const volatile int>; // int
// Remove reference
using T5 = std::remove_reference_t<int&>; // int
using T6 = std::remove_reference_t<int&&>; // int
using T7 = std::remove_reference_t<int>; // int
// Remove pointer
using T8 = std::remove_pointer_t<int*>; // int
using T9 = std::remove_pointer_t<int**>; // int*
Decay
decay mimics what happens to a parameter passed by value in ordinary function calls: arrays decay to a pointer to their first element, function types decay to function pointers, and top-level cv-qualifiers and references are stripped. This is exactly the rule template argument deduction uses for by-value parameters, which is why decay_t shows up constantly in generic code that needs to compute “the type this value would become if stored by value” — for example, deciding what type to use for a std::tuple or std::vector element built from a forwarding-reference parameter.
// Decay: array/function to pointer, remove cv and reference
using T1 = std::decay_t<int&>; // int
using T2 = std::decay_t<const int&>; // int
using T3 = std::decay_t<int[10]>; // int*
using T4 = std::decay_t<int(int)>; // int(*)(int)
Add qualifiers
// Add const
using T1 = std::add_const_t<int>; // const int
// Add pointer
using T2 = std::add_pointer_t<int>; // int*
// Add lvalue reference
using T3 = std::add_lvalue_reference_t<int>; // int&
// Add rvalue reference
using T4 = std::add_rvalue_reference_t<int>; // int&&
Type relationships
is_same_v<int, int&> being false (not just “similar”) is a common source of confusion — is_same checks for exact type identity, treating int, int&, and const int as three completely distinct types with no built-in notion of “compatible” or “convertible.” is_convertible, by contrast, actually attempts (at compile time, via SFINAE) to construct the target type from the source, so it will say int is convertible to double even though they’re not the “same” type. is_base_of answers a third, different question — public/private/protected inheritance relationship — and is notably true even when Base and Derived are the same type (a type is considered a base of itself), which trips people writing generic dispatch code that assumes strict inheritance.
#include <type_traits>
// Same type
static_assert(std::is_same_v<int, int>);
static_assert(!std::is_same_v<int, long>);
static_assert(!std::is_same_v<int, int&>);
// Convertible
static_assert(std::is_convertible_v<int, double>);
static_assert(std::is_convertible_v<int*, void*>);
static_assert(!std::is_convertible_v<int, std::string>);
// Base of
class Base {};
class Derived : public Base {};
static_assert(std::is_base_of_v<Base, Derived>);
static_assert(!std::is_base_of_v<Derived, Base>);
SFINAE with enable_if
SFINAE stands for “Substitution Failure Is Not An Error” — when the compiler substitutes a template argument and the result is an ill-formed type (rather than an ill-formed expression), it silently removes that overload from consideration instead of issuing a hard compile error. enable_if_t<Condition, T> exploits this directly: when Condition is false, enable_if has no nested ::type member, so referring to enable_if_t<...> in the return type becomes ill-formed, and the whole function overload disappears from the candidate set as if it were never declared. When Condition is true, enable_if_t<Condition, T> simply evaluates to T, so the function behaves as if the enable_if wrapper wasn’t there at all.
Function overloading
#include <type_traits>
#include <iostream>
// For integral types
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
twice(T value) {
return value * 2;
}
// For floating-point types
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, T>
twice(T value) {
return value * 2.0;
}
int main() {
std::cout << twice(5) << "\n"; // 10 (integral version)
std::cout << twice(2.5) << "\n"; // 5.0 (floating version)
}
Return type SFINAE
This is SFINAE working through decltype rather than enable_if: the trailing return type decltype(container[0]) is only well-formed if T actually has an operator[]. For std::vector<int>, that expression is valid, so the function participates in overload resolution normally. For std::list<int>, which has no operator[], the substitution fails inside the trailing return type — and because this happens during template argument deduction rather than inside the function body, it’s a SFINAE-friendly failure that simply removes the overload, rather than a hard error, which matters if there’s another overload (or a fallback) that could handle list instead.
template<typename T>
auto getValue(T& container) -> decltype(container[0]) {
return container[0];
}
std::vector<int> vec = {1, 2, 3};
int first = getValue(vec); // OK: vector has operator[]
// std::list<int> lst = {1, 2, 3};
// auto x = getValue(lst); // Error: list doesn't have operator[]
if constexpr branches
Unlike a runtime if, if constexpr discards the untaken branches entirely during compilation — for a given instantiation of T, only the matching branch’s code is actually compiled and generated as part of that instantiation. This is what makes it viable for cases where the other branches wouldn’t even compile for that T (e.g., calling .begin() on a type that doesn’t have it): with a plain if, all branches must be valid C++ for every instantiation, since the compiler can’t know at compile time which branch runs; if constexpr sidesteps that requirement entirely, which is why it largely replaced tag-dispatch and tricky enable_if overload sets for this kind of type-dependent branching once C++17 shipped.
#include <type_traits>
#include <iostream>
#include <vector>
template<typename T>
void process(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "Integer: " << value << "\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "Float: " << value << "\n";
} else if constexpr (std::is_pointer_v<T>) {
std::cout << "Pointer: " << value << "\n";
} else {
std::cout << "Other type\n";
}
}
int main() {
process(42); // "Integer: 42"
process(3.14); // "Float: 3.14"
int x = 10;
process(&x); // "Pointer: 0x..."
process("hello"); // "Other type"
}
Custom traits with void_t
void_t is a deceptively simple utility — it’s just template<typename...> using void_t = void; — but it enables a general pattern for detecting “does this expression compile for T” at compile time. The trick is partial specialization: the primary template has_value_type<T, typename = void> matches by default (giving false_type), but a more specific partial specialization has_value_type<T, void_t<typename T::value_type>> is preferred by the compiler whenever T::value_type is a valid nested type, because a more specialized partial specialization always wins. If T::value_type doesn’t exist, substitution into that specialization fails (SFINAE again), so the compiler falls back to the primary template. The second template parameter defaulting to void is what makes the two specializations comparable — both ultimately resolve to <T, void>, letting overload resolution pick the more specific one.
Detect member types
#include <type_traits>
// Primary template
template<typename T, typename = void>
struct has_value_type : std::false_type {};
// Specialization for types with value_type
template<typename T>
struct has_value_type<T, std::void_t<typename T::value_type>>
: std::true_type {};
template<typename T>
inline constexpr bool has_value_type_v = has_value_type<T>::value;
// Usage
static_assert(has_value_type_v<std::vector<int>>);
static_assert(!has_value_type_v<int>);
Detect member functions
std::declval<T>() produces an unevaluated “pretend” value of type T&& without requiring T to be default-constructible or even constructible at all — it only exists inside decltype/sizeof-style unevaluated contexts and would cause a linker error if you actually tried to call it at runtime. That’s exactly what you want here: decltype(std::declval<T>().size()) asks “if I had a T, would calling .size() on it compile, and if so, what would it return” purely as a compile-time check, without needing a real object to test against. This is the standard idiom for detecting arbitrary member functions via SFINAE, and it generalizes to checking for any expression, not just method calls.
#include <type_traits>
#include <utility>
// Detect if type has size() method
template<typename T, typename = void>
struct has_size : std::false_type {};
template<typename T>
struct has_size<T, std::void_t<
decltype(std::declval<T>().size())
>> : std::true_type {};
template<typename T>
inline constexpr bool has_size_v = has_size<T>::value;
// Usage
static_assert(has_size_v<std::vector<int>>);
static_assert(has_size_v<std::string>);
static_assert(!has_size_v<int>);
Detect container
Listing multiple requirements inside a single void_t<...> works because void_t collapses all of its template arguments to void as long as every single one of them is well-formed — if any one of T::value_type, T::iterator, .begin(), .end(), or .size() fails to compile for a given T, the whole specialization fails to substitute and the primary (false_type) template is chosen instead. This “detect several things at once, all-or-nothing” pattern is exactly how a minimal “container concept” was approximated before C++20 concepts existed; the equivalent concept-based version reads far more directly as a list of requires clauses, which is one of the main ergonomic wins concepts brought.
template<typename T, typename = void>
struct is_container : std::false_type {};
template<typename T>
struct is_container<T, std::void_t<
typename T::value_type,
typename T::iterator,
decltype(std::declval<T>().begin()),
decltype(std::declval<T>().end()),
decltype(std::declval<T>().size())
>> : std::true_type {};
template<typename T>
inline constexpr bool is_container_v = is_container<T>::value;
// Usage
static_assert(is_container_v<std::vector<int>>);
static_assert(is_container_v<std::list<double>>);
static_assert(!is_container_v<int>);
Real-world examples
Generic serialization
This pattern — dispatch on type category with if constexpr, fall through to a catch-all for anything unrecognized — is a common shape for ad hoc serialization code that needs to handle a handful of known cases without pulling in a full reflection or serialization library. The is_same_v<T, std::string> branch has to come before or alongside the arithmetic check rather than relying on some implicit ordering, since if constexpr chains evaluate top to bottom like an ordinary if/else if; a type could in principle match more than one branch’s logical condition even if not in this specific example. The <unknown> fallback is important precisely because it compiles for any T — without it, calling serialize on a type this function doesn’t know how to handle would still compile and silently return a useless string, so make sure the fallback is something callers will actually notice. A static_assert in that branch is tempting but needs care before C++23: static_assert(false, ...) directly in an if constexpr branch can be rejected by some compilers even when the branch is never instantiated, because the condition doesn’t depend on T; the portable fix is a helper like template<typename> inline constexpr bool always_false = false; and asserting on always_false<T> instead, which does depend on the template parameter and is guaranteed to only fire for instantiations that actually reach that branch.
#include <type_traits>
#include <string>
#include <sstream>
template<typename T>
std::string serialize(const T& value) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_same_v<T, std::string>) {
return "\"" + value + "\"";
} else if constexpr (std::is_pointer_v<T>) {
std::ostringstream oss;
oss << static_cast<const void*>(value);
return oss.str();
} else {
return "<unknown>";
}
}
// Usage
auto s1 = serialize(42); // "42"
auto s2 = serialize(3.14); // "3.140000"
auto s3 = serialize(std::string("hello")); // "\"hello\""
Optimized swap
is_trivially_copyable is the right check here rather than is_pod (deprecated in C++20) or is_trivial: it specifically guarantees the object’s bytes can be copied with memcpy-equivalent semantics safely, which is exactly the property a raw copy-assignment fast path relies on. The sizeof(T) <= 64 cutoff is a heuristic, not a portable guarantee — copying small trivial types by value is typically as cheap as or cheaper than three move operations, but the exact break-even point depends on the target architecture and how the compiler optimizes the move path, so treat the constant as a starting point to benchmark rather than a rule to trust blindly. For non-trivial or large types, three std::moves avoid the extra copy that a naive swap (copy a to temp, copy b to a, copy temp to b) would otherwise perform.
#include <type_traits>
#include <utility>
template<typename T>
void optimized_swap(T& a, T& b) {
if constexpr (std::is_trivially_copyable_v<T> && sizeof(T) <= 64) {
// Fast path for small trivial types
T temp = a;
a = b;
b = temp;
} else {
// Use move semantics for larger types
T temp = std::move(a);
a = std::move(b);
b = std::move(temp);
}
}
Type-safe printf
This isn’t really emulating printf’s format specifiers at runtime — the "%d: "/"%f: "/"%p: " strings here are just illustrative labels printed via operator<<, not passed to an actual printf-style formatter, so there’s no format-string/argument mismatch to worry about; the actual type safety comes from if constexpr selecting the correct std::cout formatting path for each argument’s real type at compile time, with no runtime type inspection needed. The fold expression (print_value(args), ...) in safe_printf expands to print_value(arg1), print_value(arg2), ..., print_value(argN) using the comma operator, calling print_value once per argument in a single unrolled expression — the standard idiom for “do something to every argument in a parameter pack” without recursion.
#include <type_traits>
#include <iostream>
template<typename T>
void print_value(const T& value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "%d: " << value;
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "%f: " << value;
} else if constexpr (std::is_pointer_v<T>) {
std::cout << "%p: " << value;
} else {
std::cout << value;
}
}
template<typename... Args>
void safe_printf(Args... args) {
(print_value(args), ...);
std::cout << "\n";
}
// Usage
safe_printf(42, 3.14, "hello");
Performance implications
Zero runtime cost: All type traits are evaluated at compile time. This isn’t a vague claim — it follows directly from how if constexpr works: the condition is_integral_v<T> is a constexpr boolean known at compile time for any concrete instantiation of T, so the compiler resolves the branch during template instantiation and only emits machine code for the branch actually taken. There’s no runtime type check, no virtual dispatch, and no branch instruction left in the generated assembly for the untaken path — the two instantiations of add below are, for all practical purposes, two entirely separate, independently optimized functions that happen to share source text.
// Both produce identical assembly
template<typename T>
T add(T a, T b) {
if constexpr (std::is_integral_v<T>) {
return a + b; // Integer addition
} else {
return a + b; // Floating-point addition
}
}
int x = add(5, 3); // Compiles to: add eax, ebx
double y = add(2.5, 1.5); // Compiles to: addsd xmm0, xmm1
Type traits vs C++20 Concepts
Under the hood, std::integral (the concept) is itself defined in terms of is_integral_v — concepts in the standard library are largely a more readable syntax layered over the same type traits, not a replacement mechanism. The practical difference shows up at the call site and in diagnostics: a constraint violation with template<std::integral T> produces an error that names the unsatisfied concept directly, while a SFINAE failure from enable_if typically surfaces as “no matching overload” with a wall of substitution-failure details, since the compiler has no structured way to say why the enabling condition failed.
Type traits (C++11+)
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
square(T x) {
return x * x;
}
Concepts (C++20+)
template<std::integral T>
T square(T x) {
return x * x;
}
Concepts advantages:
- More readable
- Better error messages
- Can be composed Type traits advantages:
- Works in C++11/14/17
- More flexible for complex conditions
Compiler support
| Compiler | <type_traits> | _v helpers | if constexpr |
|---|---|---|---|
| GCC | 4.3+ | 7+ (C++17) | 7+ (C++17) |
| Clang | 3.0+ | 3.9+ (C++17) | 3.9+ (C++17) |
| MSVC | 2010+ | 2015+ (C++17) | 2017+ (C++17) |
Frequently Asked Questions (FAQ)
Q. Why does is_integral_v return false for an int argument in my forwarding function?
A. With a forwarding reference T&&, passing an lvalue int deduces T as int&, and std::is_integral_v<int&> is false because a reference is not an integral type. Strip the reference and cv-qualifiers before asking: std::is_integral_v<std::remove_cvref_t<T>> in C++20, or std::decay_t<T> in older code. The same trap affects is_const and is_same checks on deduced template parameters.
Q. Runtime overhead?
A. None — every trait shown here resolves to a constexpr value evaluated during compilation, and if constexpr strips the untaken branches from the generated code entirely.
Q. Traits vs concepts?
A. In C++20 and later, concepts usually read better at the call site and produce clearer error messages, but they’re built on the same underlying traits — traits remain essential for library code that must support pre-C++20 standards, for low-level detection idioms like the void_t pattern above, and for cases where you need the trait’s boolean value directly rather than as a constraint.
Related posts
- SFINAE in C++: enable_if, Expression SFINAE and When to Switch to Concepts
- C++ Template Lambda guide
- if constexpr
- C++20 Concepts