Type Traits

The <type_traits> header lets code ask questions about a type — is it a pointer, is it the same as another type, what does it look like with const and references stripped away — and get the answer at compile time, with zero runtime cost.

Generic code frequently needs to behave differently depending on properties of the type it was instantiated with — copy a trivially copyable type with memcpy instead of a loop of assignments, forward a reference correctly instead of accidentally copying, or reject a template argument that does not make sense for a given algorithm. Type traits are the standard library's answer: a large family of small template structs in <type_traits>, each of which answers one yes/no question or performs one type-level transformation, entirely during compilation. Every trait is either a boolean question (is_integral<T>, is_same<T, U>), a transformation that produces a new type (remove_const<T>, decay<T>), or a compile-time branch that picks between two types or two overloads (conditional<T, U>, enable_if<Cond, T>). This page works through the traits you will actually reach for, and how they let generic code specialize its own behavior without giving up genericity.

What a Type Trait Actually Is

A type trait is just a template struct with a static member — usually a bool named value, or a nested type named type — that the compiler resolves fully at compile time. Nothing about it runs when the program executes; it only affects what the compiler decides to compile.

std::is_integral<int>::value and std::is_integral_v<int> mean exactly the same thing — the _v suffix is just a variable-template shorthand for ::value, added because typing ::value everywhere got tedious. Both are bool constants known at compile time, not runtime lookups.

#include <type_traits>
#include <iostream>

int main()
{
    std::cout << std::boolalpha;
    std::cout << std::is_integral<int>::value << "\n";   // true
    std::cout << std::is_integral<double>::value << "\n"; // false

    // C++14 introduced the shorter _v variable-template form:
    std::cout << std::is_integral_v<int> << "\n";         // same as ::value
}

Category Traits: is_integral, is_floating_point, is_pointer, is_reference, is_const

These traits classify a type by its fundamental category. They are frequently combined with static_assert to document and enforce a template's assumptions, or with std::enable_if / concepts to select between overloads.

Each static_assert is checked while the file compiles, not while the program runs — a violated assertion stops the build with a clear error, before the incorrect assumption could ever cause a runtime bug. is_const_v<const int> is true, but note it only looks at the top-level const on the type itself: is_const_v<const int*> (a non-const pointer to const int) is false, because the pointer itself is not const, only what it points to.

This pattern shows up throughout real library code: a template accepts any T syntactically, but static_assert with is_trivially_copyable_v turns a misuse (passing a type with a user-defined copy constructor, say) into a clear compile-time error naming exactly what went wrong, instead of a subtle runtime bug or an unreadable template-instantiation failure deep in library internals.

#include <type_traits>

static_assert(std::is_integral_v<int>);
static_assert(std::is_integral_v<char>);
static_assert(!std::is_integral_v<float>);

static_assert(std::is_floating_point_v<double>);
static_assert(!std::is_floating_point_v<int>);

static_assert(std::is_pointer_v<int*>);
static_assert(!std::is_pointer_v<int>);

static_assert(std::is_reference_v<int&>);
static_assert(!std::is_reference_v<int>);

static_assert(std::is_const_v<const int>);
static_assert(!std::is_const_v<int>);
#include <type_traits>

template<typename T>
void require_pod_like(T)
{
    static_assert(std::is_trivially_copyable_v<T>,
                  "require_pod_like only accepts trivially copyable types");
}

Relational Traits: is_same and is_base_of

Where the category traits ask about one type, is_same and is_base_of compare two types against each other.

is_same_v<A, B> is true only when A and B are the exact same type, including cv-qualifiers — int and const int compare unequal. is_base_of_v<Base, Derived> is true when Base is a base class of Derived (directly or indirectly) or when the two types are identical; it does not require Base to be publicly inherited or the inheritance to involve virtual functions at all.

if constexpr (C++17) evaluates the condition at compile time and discards the untaken branches entirely — they are not even required to compile for types that never reach them. Combined with is_same_v, this gives generic code a clean, readable way to special-case specific types without a separate overload or a full specialization.

#include <type_traits>

static_assert(std::is_same_v<int, int>);
static_assert(!std::is_same_v<int, unsigned int>);
static_assert(!std::is_same_v<int, const int>); // const is part of the type here

struct Animal {};
struct Dog : Animal {};
struct Cat : Animal {};

static_assert(std::is_base_of_v<Animal, Dog>);
static_assert(std::is_base_of_v<Animal, Cat>);
static_assert(!std::is_base_of_v<Dog, Cat>);
#include <type_traits>
#include <iostream>

template<typename T>
void log_kind()
{
    if constexpr (std::is_same_v<T, int>)
        std::cout << "handling an int\n";
    else if constexpr (std::is_same_v<T, double>)
        std::cout << "handling a double\n";
    else
        std::cout << "handling something else\n";
}

Transformation Traits: remove_reference, remove_const, decay

Not every trait produces a bool — some produce a new type, exposed as a nested ::type member (or, with the _t suffix added in C++14, directly as an alias). remove_reference<T> strips a reference off T if it has one; remove_const<T> strips a top-level const; decay<T> does both of those plus array-to-pointer and function-to-pointer conversions, mimicking exactly what happens when you pass an argument to a function by value.

remove_reference_t<int&> and remove_reference_t<int&&> both collapse to plain int — reference-ness of either kind is removed, but decay_t<const int&> goes further, stripping both the reference and the const to also land on plain int. decay_t<int[5]> becomes int* and decay_t of a function type becomes a function pointer type, matching how arrays and functions naturally decay to pointers when passed by value in ordinary C++ code.

#include <type_traits>

static_assert(std::is_same_v<std::remove_reference_t<int&>, int>);
static_assert(std::is_same_v<std::remove_reference_t<int&&>, int>);
static_assert(std::is_same_v<std::remove_const_t<const int>, int>);

// decay strips references AND top-level const/volatile, and also
// turns array/function types into pointer types -- exactly what
// happens when a value is passed to a function by value.
static_assert(std::is_same_v<std::decay_t<const int&>, int>);
static_assert(std::is_same_v<std::decay_t<int[5]>, int*>);
static_assert(std::is_same_v<std::decay_t<int(int)>, int(*)(int)>);

conditional and enable_if as Compile-Time Branches

std::conditional<Cond, T, U>::type resolves to T if Cond is true and U if Cond is false — an if/else statement that operates on types instead of values, evaluated entirely at compile time.

conditional_t<UseWide, int64_t, int32_t> picks int64_t or int32_t entirely based on the compile-time boolean UseWide — nothing about this branch survives into the generated machine code; whichever type is chosen is simply what IntChoice<...>::type means from then on.

std::enable_if<Cond, T>::type — covered in depth on the advanced templates page — is really just conditional's more aggressive sibling: instead of choosing between two types, it either produces T (when Cond is true) or produces nothing at all (when Cond is false), which is exactly the mechanism SFINAE uses to remove an overload from consideration.

For an unsigned T, is_signed_v<T> is false, so the second overload's enable_if_t has no ::type and it drops out of the candidate set entirely; only the first overload — which skips the (meaningless, and potentially warning-generating) negation — remains.

#include <type_traits>
#include <cstdint>

template<bool UseWide>
struct IntChoice
{
    using type = std::conditional_t<UseWide, int64_t, int32_t>;
};

static_assert(std::is_same_v<IntChoice<true>::type, int64_t>);
static_assert(std::is_same_v<IntChoice<false>::type, int32_t>);
#include <type_traits>

template<typename T>
std::enable_if_t<std::is_unsigned_v<T>, T>
abs_value(T x)
{
    return x; // already non-negative by construction
}

template<typename T>
std::enable_if_t<std::is_signed_v<T>, T>
abs_value(T x)
{
    return x < 0 ? -x : x;
}

Compile-Time Type Inspection in Practice

Type traits are rarely interesting on their own — they matter because they let generic code make decisions that would otherwise require either duplicated functions or runtime type checks (which C++ mostly avoids, for both performance and design reasons). A realistic example: a generic print function that formats pointers differently from values, entirely at compile time.

is_pointer_v<T> decides which branch if constexpr keeps: for smart_print(x), T deduces to int, the pointer branch is discarded before compilation even checks whether *value would make sense, and only the else branch is compiled. For smart_print(&x) and smart_print(p), T deduces to int*, and the reverse happens. No runtime type check or branch survives into either generated function — each instantiation only ever contains the branch that actually applies to it.

#include <iostream>
#include <type_traits>

template<typename T>
void smart_print(const T& value)
{
    if constexpr (std::is_pointer_v<T>)
    {
        if (value)
            std::cout << "-> " << *value << "\n";
        else
            std::cout << "-> (null)\n";
    }
    else
    {
        std::cout << value << "\n";
    }
}

int main()
{
    int x = 5;
    smart_print(x);   // prints: 5
    smart_print(&x);  // prints: -> 5
    int* p = nullptr;
    smart_print(p);   // prints: -> (null)
}

Building a Custom Trait

The standard traits cover the common cases, but nothing stops you from writing your own using the exact same specialization technique — this is precisely how the detection idiom from the advanced templates page works.

has_begin_end<T> answers 'is T iterable with begin()/end()?' the same way std::is_pointer answers 'is T a pointer?' — a primary template that defaults to false_type, and a partial specialization that only matches (via void_t collapsing two decltype expressions to void) when both .begin() and .end() are valid on T. Adding the _v alias makes it usable exactly like a standard trait.

#include <type_traits>
#include <vector>

template<typename T, typename = void>
struct has_begin_end : std::false_type {};

template<typename T>
struct has_begin_end<T, std::void_t<decltype(std::declval<T>().begin()),
                                     decltype(std::declval<T>().end())>>
    : std::true_type {};

template<typename T>
inline constexpr bool has_begin_end_v = has_begin_end<T>::value;

static_assert(has_begin_end_v<std::vector<int>>);
static_assert(!has_begin_end_v<int>);

Best Practices

Prefer the _v and _t suffixed forms (is_integral_v<T>, remove_reference_t<T>) over the older ::value / ::type spellings — they are equivalent but far less noisy, and they are what modern codebases and the standard library itself use internally. Reach for decay_t specifically when mimicking pass-by-value semantics, and remove_reference_t / remove_cv_t when you need something narrower. Combine traits with static_assert at the top of a template to document its real requirements as an enforced compile-time contract, not just a comment. And prefer if constexpr over enable_if-based overload splitting when you are branching inside one function body rather than choosing between whole separate functions — it is simpler to read and does not require the SFINAE machinery at all.

The mental model: every trait is a small compile-time question-and-answer or type-transformation machine, built out of ordinary class template specialization. Learning to read <type_traits> is really learning to read the specialization technique from the advanced templates page applied over and over, once for each question the standard library thought was common enough to name.

What is the difference between std::is_integral<T>::value and std::is_integral_v<T>, and when was the _v form introduced?

They mean exactly the same thing — is_integral_v<T> is a C++14 variable-template shorthand defined as is_integral<T>::value. There is no functional difference; the _v form exists purely to avoid writing ::value repeatedly, and is the preferred spelling in modern code.

Why is std::is_same_v<int, const int> false, and what does that imply about how is_same treats cv-qualifiers?

is_same_v compares types exactly, including their cv-qualifiers (const/volatile), not just their underlying base type — int and const int are considered different types by is_same, even though a const int can be implicitly converted to int. Comparing after normalizing qualifiers requires explicitly stripping them first, e.g. with is_same_v<std::remove_const_t<T>, int>.

What does std::decay_t<T> do that std::remove_reference_t<T> alone does not?

remove_reference_t only strips a reference (lvalue or rvalue) off T. decay_t does that too, but also strips top-level const/volatile qualifiers and converts array types to pointer types and function types to function-pointer types — mimicking exactly what happens to a type when a value of it is passed to a function by value.

How does std::conditional_t<Cond, A, B> differ from std::enable_if_t<Cond, T> in what happens when Cond is false?

conditional_t<Cond, A, B> always produces a valid type either way: A when Cond is true, B when Cond is false — it is a compile-time if/else between two types. enable_if_t<Cond, T> produces T only when Cond is true; when Cond is false, it produces no type at all, causing a substitution failure wherever it is used — which is exactly the mechanism SFINAE relies on to remove an overload or specialization from consideration.

In the custom has_begin_end trait built with void_t, what specifically causes the partial specialization to be rejected for a type like int?

The partial specialization only matches when std::void_t<decltype(std::declval<T>().begin()), decltype(std::declval<T>().end())> is well-formed. For int, neither .begin() nor .end() is a valid member call, so both decltype expressions fail to compile. That substitution failure happens in the specialization's template argument list — an immediate context — so SFINAE silently discards the specialization rather than raising a hard error, leaving the primary template (false_type) as the only match.

Write a C++ template function generic_max(T a, T b) constrained with static_assert to only accept arithmetic types (using std::is_arithmetic_v), and demonstrate it with int and double arguments.

static_assert(std::is_arithmetic_v<T>, ...) is checked the moment generic_max<T> is instantiated. Both int and double satisfy is_arithmetic_v, so generic_max(3, 7) prints 7 and generic_max(2.5, 1.5) prints 2.5. If generic_max were called with, say, a std::string, the build would fail immediately at that call site with the message "generic_max requires an arithmetic type" rather than a cryptic error about operator> or a silently wrong comparison.

What does this program print, and why does compare_types<const int&, int>() print what it does?

It prints true, then false. Cleaned<T> strips both cv-qualifiers and references, so Cleaned<const int&> and Cleaned<int> both become plain int, making is_same_v<int, int> true. For the second call, Cleaned<int&&> becomes int while Cleaned<double> stays double, and int is not the same type as double, so that comparison is false.

Write a C++ program using std::conditional_t to define a type alias Selected<Big> that is long long when Big is true and int when Big is false, and print sizeof(Selected<true>) and sizeof(Selected<false>) to confirm the difference.

Selected<true> resolves to long long and Selected<false> resolves to int, purely at compile time via std::conditional_t. On a typical platform this prints sizeof(Selected<true>) = 8 and sizeof(Selected<false>) = 4, showing that the alias genuinely names two different concrete types depending on the compile-time boolean, not a single type with runtime-varying size.

What does each call to inspect() print, and why does inspect<int&>() not print "pointer" or fall through to "plain value"?

It prints, in order: "plain value", "pointer", "reference", "const value". inspect<int&>() prints "reference" because is_pointer_v<int&> is false (a reference is not a pointer) so that branch is skipped, but is_reference_v<int&> is true, so if constexpr keeps that branch and discards the rest — the else-if chain stops at the first true condition, exactly like an ordinary if/else if/else, just evaluated and pruned at compile time instead of at runtime.

#include <iostream>
#include <type_traits>

template<typename T>
using Cleaned = std::remove_cv_t<std::remove_reference_t<T>>;

template<typename A, typename B>
void compare_types()
{
    std::cout << std::boolalpha << std::is_same_v<Cleaned<A>, Cleaned<B>> << "\n";
}

int main()
{
    compare_types<const int&, int>();
    compare_types<int&&, double>();
}
#include <iostream>
#include <type_traits>

template<typename T>
void inspect()
{
    if constexpr (std::is_pointer_v<T>)
        std::cout << "pointer\n";
    else if constexpr (std::is_reference_v<T>)
        std::cout << "reference\n";
    else if constexpr (std::is_const_v<T>)
        std::cout << "const value\n";
    else
        std::cout << "plain value\n";
}

int main()
{
    inspect<int>();
    inspect<int*>();
    inspect<int&>();
    inspect<const int>();
}