C++ Type Traits & SFINAE: Compile-Time Type Inspection Guide
Table of Contents
What Are Type Traits?
Type traits are compile-time queries about types. They live in the <type_traits> header and answer questions like “Is this type an integer?”, “Is it a pointer?”, “Can it be copied?”. Each trait is a class template that exposes a ::value (a compile-time boolean) or a ::type (a transformed type).
Type traits power template metaprogramming — they let you write generic code that adapts its behavior based on the properties of the types it receives. Without type traits, you would have no way to ask “does this type have a .size() method?” or “is this type trivially copyable?” at compile time.
#include <type_traits>
#include <iostream>
int main() {
// Query traits — each has a ::value member
std::cout << std::is_integral<int>::value << "\n"; // 1 (true)
std::cout << std::is_integral<double>::value << "\n"; // 0 (false)
std::cout << std::is_pointer<int*>::value << "\n"; // 1
std::cout << std::is_same<int, int32_t>::value << "\n"; // 1
// C++17 shorthand: _v suffix
std::cout << std::is_integral_v<int> << "\n"; // 1
std::cout << std::is_floating_point_v<float> << "\n"; // 1
}
The _v suffix (C++17) is a variable template shorthand: std::is_integral_v<T> is equivalent to std::is_integral<T>::value. Similarly, _t suffixes provide type shortcuts: std::remove_const_t<T> instead of typename std::remove_const<T>::type.
Primary Type Categories
The standard library provides traits for every fundamental type category:
#include <type_traits>
#include <string>
#include <vector>
// Fundamental type checks
static_assert(std::is_void_v<void>);
static_assert(std::is_integral_v<int>);
static_assert(std::is_integral_v<bool>); // bool counts as integral
static_assert(std::is_integral_v<char>); // char too
static_assert(std::is_floating_point_v<double>);
static_assert(std::is_arithmetic_v<int>); // integral or floating
static_assert(std::is_fundamental_v<int>); // arithmetic, void, nullptr_t
// Compound type checks
static_assert(std::is_pointer_v<int*>);
static_assert(std::is_reference_v<int&>);
static_assert(std::is_lvalue_reference_v<int&>);
static_assert(std::is_rvalue_reference_v<int&&>);
static_assert(std::is_array_v<int[5]>);
static_assert(std::is_enum_v<std::byte>);
static_assert(std::is_class_v<std::string>);
static_assert(std::is_function_v<void(int)>);
// Composite checks
static_assert(std::is_object_v<int>); // not function, not ref, not void
static_assert(std::is_scalar_v<int*>); // arithmetic, pointer, enum, nullptr
Property traits check deeper characteristics like copyability, constructibility, and triviality:
static_assert(std::is_trivially_copyable_v<int>);
static_assert(std::is_trivially_copyable_v<double>);
static_assert(!std::is_trivially_copyable_v<std::string>); // has allocator
static_assert(std::is_default_constructible_v<std::vector<int>>);
static_assert(std::is_copy_constructible_v<std::string>);
static_assert(!std::is_copy_constructible_v<std::unique_ptr<int>>); // move-only
static_assert(std::is_move_constructible_v<std::unique_ptr<int>>);
static_assert(std::is_assignable_v<int&, double>); // can assign double to int&
Type Transformations
Transformation traits modify types at compile time. They strip qualifiers, add references, change signedness, and more:
#include <type_traits>
// Remove qualifiers
using A = std::remove_const_t<const int>; // int
using B = std::remove_volatile_t<volatile int>; // int
using C = std::remove_cv_t<const volatile int>; // int
using D = std::remove_reference_t<int&>; // int
using E = std::remove_reference_t<int&&>; // int
using F = std::remove_pointer_t<int*>; // int
// Add qualifiers
using G = std::add_const_t<int>; // const int
using H = std::add_lvalue_reference_t<int>; // int&
using I = std::add_pointer_t<int>; // int*
// Decay — mimics pass-by-value semantics
using J = std::decay_t<int[5]>; // int*
using K = std::decay_t<void(int)>; // void(*)(int)
using L = std::decay_t<const int&>; // int
// Conditional type selection
using M = std::conditional_t<true, int, double>; // int
using N = std::conditional_t<false, int, double>; // double
// Common type
using O = std::common_type_t<int, double>; // double
using P = std::common_type_t<int, long, float>; // float
std::decay_t is particularly important — it strips references, removes const/volatile, decays arrays to pointers, and decays functions to function pointers. It mimics what happens when you pass a value to a function by value.
SFINAE Explained
SFINAE stands for Substitution Failure Is Not An Error. When the compiler tries to instantiate a template and the substitution of template arguments produces an invalid type, that particular overload is silently removed from the candidate set instead of causing a compilation error:
#include <type_traits>
#include <iostream>
// This overload is only valid when T has a .size() method
template<typename T>
auto get_size(const T& container) -> decltype(container.size()) {
return container.size();
}
// Fallback for types without .size()
template<typename T>
std::size_t get_size(const T& arr) {
return sizeof(arr);
}
int main() {
std::vector<int> v{1, 2, 3};
int arr[5] = {};
std::cout << get_size(v) << "\n"; // 3 — calls first overload
std::cout << get_size(arr) << "\n"; // 20 — calls second overload
}
When the compiler considers get_size(arr), it tries the first overload. decltype(arr.size()) is invalid because int[5] has no .size() method. SFINAE kicks in: this overload is silently discarded, and the compiler picks the fallback. No error.
enable_if — The Classic SFINAE Tool
std::enable_if conditionally enables or disables a template overload based on a boolean condition:
// Only enabled for integral types
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
absolute(T value) {
return value < 0 ? -value : value;
}
// Only enabled for floating-point types
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, T>
absolute(T value) {
return value < 0.0 ? -value : value;
}
int main() {
std::cout << absolute(-42) << "\n"; // 42 (int overload)
std::cout << absolute(-3.14) << "\n"; // 3.14 (double overload)
// absolute("hello"); // ERROR: no matching overload
}
std::enable_if_t<condition, ReturnType> resolves to ReturnType when the condition is true. When false, the substitution fails (SFINAE), and the overload is removed. You can also use enable_if as a default template parameter:
template<typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void process(T value) {
std::cout << "Integer: " << value << "\n";
}
template<typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void process(T value) {
std::cout << "Float: " << value << "\n";
}
if constexpr — The Modern Replacement
C++17’s if constexpr evaluates conditions at compile time and discards the false branch entirely. It replaces most SFINAE use cases with cleaner syntax:
template<typename T>
auto serialize(const T& value) {
if constexpr (std::is_integral_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_floating_point_v<T>) {
// Fixed precision for floats
std::ostringstream oss;
oss << std::fixed << std::setprecision(2) << value;
return oss.str();
} else if constexpr (std::is_same_v<T, std::string>) {
return "\"" + value + "\"";
} else {
static_assert(always_false<T>, "Unsupported type");
}
}
// Helper for static_assert in else branch
template<typename> constexpr bool always_false = false;
int main() {
std::cout << serialize(42) << "\n"; // "42"
std::cout << serialize(3.14159) << "\n"; // "3.14"
std::cout << serialize(std::string("hi")) << "\n"; // "\"hi\""
}
The key difference from a regular if: the discarded branch doesn’t need to be valid for the current type. With a normal if, both branches must compile even if only one executes. With if constexpr, the compiler only checks the branch that’s taken.
Writing Custom Type Traits
You can write your own type traits using template specialization and SFINAE detection idioms:
#include <type_traits>
#include <string>
#include <vector>
// Detect if T has a .push_back() method
template<typename T, typename = void>
struct has_push_back : std::false_type {};
template<typename T>
struct has_push_back<T, std::void_t<
decltype(std::declval<T>().push_back(std::declval<typename T::value_type>()))
>> : std::true_type {};
template<typename T>
constexpr bool has_push_back_v = has_push_back<T>::value;
// Detect if T is iterable (has begin/end)
template<typename T, typename = void>
struct is_iterable : std::false_type {};
template<typename T>
struct is_iterable<T, std::void_t<
decltype(std::begin(std::declval<T>())),
decltype(std::end(std::declval<T>()))
>> : std::true_type {};
static_assert(has_push_back_v<std::vector<int>>);
static_assert(!has_push_back_v<std::array<int, 5>>);
static_assert(is_iterable<std::vector<int>>::value);
static_assert(!is_iterable<int>::value);
std::void_t (C++17) is the magic ingredient. It takes any number of types and resolves to void. If any of the decltype expressions inside it are invalid, SFINAE discards the specialization, and the primary template (inheriting false_type) wins.
Concepts vs SFINAE
C++20 concepts largely replace SFINAE for constraining templates. Compare the same constraint written three ways:
// 1. SFINAE with enable_if (C++11)
template<typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
void process_sfinae(T value) { /* ... */ }
// 2. if constexpr (C++17)
template<typename T>
void process_constexpr(T value) {
if constexpr (std::is_integral_v<T>) { /* ... */ }
else { /* fallback */ }
}
// 3. Concepts (C++20)
template<std::integral T>
void process_concept(T value) { /* ... */ }
// Or with requires clause
template<typename T> requires std::integral<T>
void process_requires(T value) { /* ... */ }
Concepts produce dramatically better error messages. With SFINAE, a constraint failure gives you pages of template substitution errors. With concepts, you get: “constraint ‘std::integral<T>’ not satisfied.” Use concepts when targeting C++20. Use SFINAE or if constexpr for C++11/14/17 codebases.
Real-World Examples
#include <type_traits>
#include <cstring>
#include <algorithm>
#include <vector>
// Optimized copy: use memcpy for trivially copyable types
template<typename T>
void fast_copy(T* dest, const T* src, std::size_t count) {
if constexpr (std::is_trivially_copyable_v<T>) {
std::memcpy(dest, src, count * sizeof(T));
} else {
for (std::size_t i = 0; i < count; ++i) {
dest[i] = src[i];
}
}
}
// Generic container printer
template<typename T>
void print_value(const T& val) {
if constexpr (std::is_arithmetic_v<T>) {
std::cout << val;
} else if constexpr (std::is_same_v<std::decay_t<T>, std::string>) {
std::cout << '"' << val << '"';
} else if constexpr (is_iterable<T>::value) {
std::cout << "[";
bool first = true;
for (const auto& elem : val) {
if (!first) std::cout << ", ";
print_value(elem);
first = false;
}
std::cout << "]";
} else {
std::cout << "(unknown)";
}
}
// Safe numeric cast — only allows non-narrowing conversions
template<typename To, typename From>
auto safe_cast(From value) -> std::enable_if_t<
std::is_arithmetic_v<From> && std::is_arithmetic_v<To>, To>
{
static_assert(sizeof(To) >= sizeof(From),
"Potential narrowing conversion");
return static_cast<To>(value);
}
Common Mistakes
Forgetting typename before dependent types. When accessing ::type inside a template, you need typename: typename std::enable_if<cond, T>::type. The _t suffix aliases avoid this entirely.
Using static_assert without always_false in else branches. A bare static_assert(false, "...") in an if constexpr else branch fires unconditionally because the compiler checks it even without instantiation. Wrap it: static_assert(always_false<T>, "...").
Checking the wrong trait. std::is_same_v<T, std::string> won’t match const std::string&. Use std::decay_t<T> first to strip qualifiers and references, or use std::is_same_v<std::decay_t<T>, std::string>.
Summary
Type traits provide compile-time type introspection through <type_traits>. They query type properties (is_integral, is_pointer, is_trivially_copyable), transform types (remove_const, add_reference, decay), and power conditional compilation. SFINAE silently removes invalid template overloads, enable_if leverages SFINAE for explicit constraints, and if constexpr (C++17) offers a cleaner alternative for compile-time branching. C++20 concepts replace most SFINAE patterns with readable syntax and better errors. In the next lesson, you’ll meet the Standard Template Library — where type traits, templates, and variadic templates all come together in C++’s container and algorithm framework.