Type Traits in C++: Compile-Time Type Information

Type traits in C++ are compile-time templates that answer questions about types or transform types. Defined in <type_traits>, they take a type as a template argument and expose information through a ::value member (true/false) or a ::type member (a transformed type). For example, std::is_integral<int>::value is true, std::is_pointer<int*>::value is true, and std::remove_const<const int>::type is int. Type traits are the foundation of if constexpr branches, SFINAE constraints, and Concepts.

Introduction

Templates in C++ are powerful — they let you write one function or class that works with many types. But sometimes “many types” is not quite right. A function that adds two numbers works for int, double, and float, but not for string or vector. A serialization function needs to handle integers differently from floating-point types, pointers differently from arrays, and user-defined types differently from all of them.

Before C++11, handling these distinctions required complex SFINAE tricks or separate template specializations for every case. C++11 standardized a comprehensive library of type traits in <type_traits> — a toolkit of compile-time predicates and type transformations that let you query and manipulate types at compile time with a clean, consistent interface.

Type traits serve four main purposes. Query traits answer yes/no questions about a type: Is it an integer? Is it a pointer? Does it have a virtual destructor? Property traits expose numeric properties: What is its alignment? How many array dimensions does it have? Transformation traits produce modified types: Remove the const. Add a pointer. Decay the type (remove cv-qualifiers and array/function decay). Relationship traits compare two types: Are they the same type? Is one derived from the other? Is one convertible to the other?

This article teaches type traits from first principles. You will understand the mechanics of how they work (template specialization), how to use the standard library traits effectively, how to write your own custom traits, and how traits combine with if constexpr, SFINAE, and Concepts to control template behavior.

The Mechanics: How Type Traits Work

Type traits are just templates with specializations. Understanding their implementation demystifies them completely.

#include <iostream>
#include <type_traits>
using namespace std;

// How is_pointer is actually implemented:
// Primary template: assume false
template<typename T>
struct my_is_pointer {
    static constexpr bool value = false;
    using type = false_type;
};

// Specialization for T* — overrides the primary template
template<typename T>
struct my_is_pointer<T*> {
    static constexpr bool value = true;
    using type = true_type;
};

// Specialization for const T* as well
template<typename T>
struct my_is_pointer<const T*> {
    static constexpr bool value = true;
    using type = true_type;
};

// How is_const is implemented:
template<typename T>
struct my_is_const : false_type {};  // Inheriting from false_type is idiomatic

template<typename T>
struct my_is_const<const T> : true_type {};  // Specialization for const T

// How remove_const is implemented:
template<typename T>
struct my_remove_const { using type = T; };        // Primary: return T unchanged

template<typename T>
struct my_remove_const<const T> { using type = T; };  // Specialization: strip const

// Convenience alias (C++14 style)
template<typename T>
using my_remove_const_t = typename my_remove_const<T>::type;

int main() {
    // Our custom traits
    cout << "=== Custom trait demos ===" << endl;
    cout << "my_is_pointer<int>:        " << my_is_pointer<int>::value   << endl;
    cout << "my_is_pointer<int*>:       " << my_is_pointer<int*>::value  << endl;
    cout << "my_is_pointer<const int*>: " << my_is_pointer<const int*>::value << endl;

    cout << "my_is_const<int>:          " << my_is_const<int>::value       << endl;
    cout << "my_is_const<const int>:    " << my_is_const<const int>::value << endl;

    // remove_const in action
    using T1 = my_remove_const_t<const double>;   // double
    using T2 = my_remove_const_t<double>;          // double (unchanged)
    cout << "remove_const<const double> == double? "
         << is_same<T1, double>::value << endl;
    cout << "remove_const<double> == double?       "
         << is_same<T2, double>::value << endl;

    // The standard library versions
    cout << "\n=== Standard <type_traits> ===" << endl;
    cout << "is_pointer<int>:           " << is_pointer<int>::value        << endl;
    cout << "is_pointer<int*>:          " << is_pointer<int*>::value       << endl;
    cout << "is_const<const int>:       " << is_const<const int>::value    << endl;
    cout << "is_same<int, int>:         " << is_same<int, int>::value      << endl;
    cout << "is_same<int, double>:      " << is_same<int, double>::value   << endl;

    // C++17 variable templates (trailing _v) — preferred in modern code
    cout << "\n=== C++17 _v shortcuts ===" << endl;
    cout << "is_pointer_v<int*>:        " << is_pointer_v<int*>        << endl;
    cout << "is_const_v<const float>:   " << is_const_v<const float>   << endl;
    cout << "is_same_v<int, int>:       " << is_same_v<int, int>       << endl;

    // C++14 _t shortcuts for type transformation traits
    cout << "\n=== C++14 _t shortcuts ===" << endl;
    using NoCv = remove_cv_t<const volatile int>;   // int
    using NoRef = remove_reference_t<int&>;          // int
    cout << "remove_cv_t<const volatile int> == int? "
         << is_same_v<NoCv, int> << endl;
    cout << "remove_reference_t<int&> == int?        "
         << is_same_v<NoRef, int> << endl;

    return 0;
}

Output:

=== Custom trait demos ===
my_is_pointer<int>:        0
my_is_pointer<int*>:       1
my_is_pointer<const int*>: 1
my_is_const<int>:          0
my_is_const<const int>:    1
remove_const<const double> == double? 1
remove_const<double> == double?       1

=== Standard <type_traits> ===
is_pointer<int>:           0
is_pointer<int*>:          1
is_const<const int>:       1
is_same<int, int>:         1
is_same<int, double>:      0

=== C++17 _v shortcuts ===
is_pointer_v<int*>:        1
is_const_v<const float>:   1
is_same_v<int, int>:       1

=== C++14 _t shortcuts ===
remove_cv_t<const volatile int> == int? 1
remove_reference_t<int&> == int?        1

Step-by-step explanation:

  1. The primary template defines the default answer — for my_is_pointer, the default is false. Template specializations for specific patterns (like T*) override the default for those patterns. The compiler selects the most specific matching specialization.
  2. Inheriting from false_type (which is integral_constant<bool, false>) is the idiomatic way to define a false-valued trait. true_type is integral_constant<bool, true>. Both provide ::value, ::type, and operator bool().
  3. my_remove_const<const T>::type is T — the specialization strips const. my_remove_const<T>::type for non-const T is just T unchanged — the primary template returns T as-is.
  4. The _v suffix (C++17) is a variable template shortcut: is_pointer_v<T> is equivalent to is_pointer<T>::value. The _t suffix (C++14) is a type alias shortcut: remove_const_t<T> is equivalent to typename remove_const<T>::type. Always prefer _v and _t in modern code — they are cleaner and less error-prone.
  5. is_same<int, int>::value is true only when both template arguments are exactly the same type. is_same<int, const int>::value is false — const is part of the type.

Query Traits: Asking Questions About Types

The standard library provides a rich set of query traits organized into categories:

#include <iostream>
#include <type_traits>
#include <string>
#include <vector>
using namespace std;

struct EmptyClass {};
struct NonTrivial {
    NonTrivial() { /* user-defined */ }
    virtual ~NonTrivial() {}
    string name;
};
struct Pod { int x; double y; };  // Plain old data

void demonstrateQueryTraits() {
    cout << "=== Primary type categories ===" << endl;
    // Exactly one of these is true for any type
    cout << "is_void<void>:             " << is_void_v<void>             << endl;
    cout << "is_integral<int>:          " << is_integral_v<int>          << endl;
    cout << "is_integral<char>:         " << is_integral_v<char>         << endl;
    cout << "is_integral<bool>:         " << is_integral_v<bool>         << endl;
    cout << "is_floating_point<double>: " << is_floating_point_v<double> << endl;
    cout << "is_array<int[5]>:          " << is_array_v<int[5]>          << endl;
    cout << "is_pointer<int*>:          " << is_pointer_v<int*>          << endl;
    cout << "is_reference<int&>:        " << is_reference_v<int&>        << endl;
    cout << "is_class<string>:          " << is_class_v<string>          << endl;
    cout << "is_enum<enum E{}>:         ";
    enum Color { Red, Green, Blue };
    cout                                  << is_enum_v<Color>            << endl;
    cout << "is_function<int(int)>:     " << is_function_v<int(int)>     << endl;

    cout << "\n=== Composite categories ===" << endl;
    cout << "is_arithmetic<int>:        " << is_arithmetic_v<int>        << endl;
    cout << "is_arithmetic<double>:     " << is_arithmetic_v<double>     << endl;
    cout << "is_arithmetic<string>:     " << is_arithmetic_v<string>     << endl;
    cout << "is_fundamental<int>:       " << is_fundamental_v<int>       << endl;
    cout << "is_fundamental<string>:    " << is_fundamental_v<string>    << endl;
    cout << "is_object<int>:            " << is_object_v<int>            << endl;
    cout << "is_scalar<int*>:           " << is_scalar_v<int*>           << endl;
    cout << "is_compound<string>:       " << is_compound_v<string>       << endl;

    cout << "\n=== Type properties ===" << endl;
    cout << "is_const<const int>:           " << is_const_v<const int>           << endl;
    cout << "is_volatile<volatile int>:     " << is_volatile_v<volatile int>     << endl;
    cout << "is_trivial<Pod>:               " << is_trivial_v<Pod>               << endl;
    cout << "is_trivial<NonTrivial>:        " << is_trivial_v<NonTrivial>        << endl;
    cout << "is_standard_layout<Pod>:       " << is_standard_layout_v<Pod>       << endl;
    cout << "is_empty<EmptyClass>:          " << is_empty_v<EmptyClass>          << endl;
    cout << "is_polymorphic<NonTrivial>:    " << is_polymorphic_v<NonTrivial>    << endl;
    cout << "is_abstract<NonTrivial>:       " << is_abstract_v<NonTrivial>       << endl;
    cout << "has_virtual_destructor<NonTrivial>: "
         << has_virtual_destructor_v<NonTrivial> << endl;

    cout << "\n=== Constructor/destructor properties ===" << endl;
    cout << "is_default_constructible<Pod>:      "
         << is_default_constructible_v<Pod>      << endl;
    cout << "is_copy_constructible<string>:      "
         << is_copy_constructible_v<string>      << endl;
    cout << "is_move_constructible<string>:      "
         << is_move_constructible_v<string>      << endl;
    cout << "is_copy_assignable<string>:         "
         << is_copy_assignable_v<string>         << endl;
    cout << "is_trivially_copyable<Pod>:         "
         << is_trivially_copyable_v<Pod>         << endl;
    cout << "is_trivially_copyable<NonTrivial>:  "
         << is_trivially_copyable_v<NonTrivial>  << endl;
    cout << "is_nothrow_move_constructible<Pod>: "
         << is_nothrow_move_constructible_v<Pod> << endl;

    cout << "\n=== Size and alignment ===" << endl;
    cout << "alignment_of<double>:      " << alignment_of_v<double>      << endl;
    cout << "alignment_of<int>:         " << alignment_of_v<int>         << endl;
    cout << "rank<int[2][3][4]>:        " << rank_v<int[2][3][4]>        << endl;
    cout << "extent<int[2][3], 0>:      " << extent_v<int[2][3], 0>      << endl;
    cout << "extent<int[2][3], 1>:      " << extent_v<int[2][3], 1>      << endl;
}

int main() {
    demonstrateQueryTraits();
    return 0;
}

Output:

=== Primary type categories ===
is_void<void>:             1
is_integral<int>:          1
is_integral<char>:         1
is_integral<bool>:         1
is_floating_point<double>: 1
is_array<int[5]>:          1
is_pointer<int*>:          1
is_reference<int&>:        1
is_class<string>:          1
is_enum<enum E{}>:         1
is_function<int(int)>:     1

=== Composite categories ===
is_arithmetic<int>:        1
is_arithmetic<double>:     1
is_arithmetic<string>:     0
is_fundamental<int>:       1
is_fundamental<string>:    0
is_object<int>:            1
is_scalar<int*>:           1
is_compound<string>:       1

=== Type properties ===
is_const<const int>:           1
is_volatile<volatile int>:     1
is_trivial<Pod>:               1
is_trivial<NonTrivial>:        0
is_standard_layout<Pod>:       1
is_empty<EmptyClass>:          1
is_polymorphic<NonTrivial>:    1
is_abstract<NonTrivial>:       0
has_virtual_destructor<NonTrivial>: 1

=== Constructor/destructor properties ===
is_default_constructible<Pod>:      1
is_copy_constructible<string>:      1
is_move_constructible<string>:      1
is_copy_assignable<string>:         1
is_trivially_copyable<Pod>:         1
is_trivially_copyable<NonTrivial>:  0
is_nothrow_move_constructible<Pod>: 1

=== Size and alignment ===
alignment_of<double>:      8
alignment_of<int>:         4
rank<int[2][3][4]>:        3
extent<int[2][3], 0>:      2
extent<int[2][3], 1>:      3

Step-by-step explanation:

  1. Primary type categories are mutually exclusive — every type belongs to exactly one. bool is both is_integral and is_arithmetic because bool is an integral type in C++.
  2. is_trivially_copyable<T> is the trait you check before using memcpy to copy objects. If true, the type’s bytes can be copied with memcpy. Pod qualifies; NonTrivial (with a user-defined constructor and string member) does not.
  3. is_polymorphic<T> is true if T has at least one virtual function (directly or inherited) — meaning it has a vtable. This detects whether an object carries the vtable pointer overhead.
  4. rank<T> returns the number of array dimensions. extent<T, N> returns the size of dimension N. These let you reason about array types generically.
  5. is_nothrow_move_constructible<T> is the trait that std::vector checks when deciding whether to move or copy elements during reallocation. If move construction is noexcept, it moves (fast, O(1)); otherwise it copies (safe, but potentially O(n)) to preserve the strong exception guarantee.

Transformation Traits: Modifying Types

Transformation traits produce new types from existing ones. They are the building blocks of generic type manipulation.

#include <iostream>
#include <type_traits>
using namespace std;

int main() {
    cout << "=== cv-qualifier transformations ===" << endl;
    // Remove const, volatile, or both (cv)
    static_assert(is_same_v<remove_const_t<const int>,    int>);
    static_assert(is_same_v<remove_volatile_t<volatile int>, int>);
    static_assert(is_same_v<remove_cv_t<const volatile int>, int>);

    // Add const/volatile
    static_assert(is_same_v<add_const_t<int>,    const int>);
    static_assert(is_same_v<add_volatile_t<int>, volatile int>);
    static_assert(is_same_v<add_cv_t<int>,       const volatile int>);

    cout << "=== Reference transformations ===" << endl;
    static_assert(is_same_v<remove_reference_t<int&>,  int>);
    static_assert(is_same_v<remove_reference_t<int&&>, int>);
    static_assert(is_same_v<remove_reference_t<int>,   int>);  // unchanged

    static_assert(is_same_v<add_lvalue_reference_t<int>, int&>);
    static_assert(is_same_v<add_rvalue_reference_t<int>, int&&>);

    cout << "=== Pointer transformations ===" << endl;
    static_assert(is_same_v<remove_pointer_t<int*>,  int>);
    static_assert(is_same_v<remove_pointer_t<int**>, int*>);  // Only one level
    static_assert(is_same_v<add_pointer_t<int>,      int*>);

    cout << "=== decay: the most important transformation ===" << endl;
    // decay<T> models what happens when T is passed by value to a function:
    // - arrays decay to pointers
    // - functions decay to function pointers
    // - cv-qualifiers are stripped
    static_assert(is_same_v<decay_t<int[5]>,        int*>);      // array → pointer
    static_assert(is_same_v<decay_t<int(double)>,   int(*)(double)>); // function → ptr
    static_assert(is_same_v<decay_t<const int&>,    int>);       // ref + const stripped
    static_assert(is_same_v<decay_t<int&&>,         int>);       // rvalue ref stripped
    static_assert(is_same_v<decay_t<int>,           int>);       // unchanged

    cout << "=== Conditional type selection ===" << endl;
    // conditional<bool, T, F>::type: if bool is true, T; else F
    using BigOrSmall = conditional_t<(sizeof(int) > 2), long long, short>;
    static_assert(is_same_v<BigOrSmall, long long>);  // On 32/64-bit systems
    cout << "BigOrSmall is 64-bit: " << (sizeof(BigOrSmall) == 8 ? "yes" : "no") << endl;

    // Nested conditional for type selection based on multiple conditions
    template_arg_demo();

    cout << "=== make_signed / make_unsigned ===" << endl;
    static_assert(is_same_v<make_signed_t<unsigned int>,  int>);
    static_assert(is_same_v<make_unsigned_t<int>,         unsigned int>);
    static_assert(is_same_v<make_signed_t<unsigned char>, signed char>);

    cout << "=== common_type ===" << endl;
    // common_type: the type that all given types can be converted to
    using CT1 = common_type_t<int, double>;          // double
    using CT2 = common_type_t<int, long, unsigned>;  // unsigned long
    cout << "common_type<int, double>: "
         << (is_same_v<CT1, double> ? "double" : "other") << endl;

    cout << "All static_asserts passed — transformations verified!" << endl;
    return 0;
}

void template_arg_demo() {
    // Select storage type based on size
    template_select<4>();
    template_select<8>();
    template_select<16>();
}

template<size_t N>
void template_select() {
    using StorageType =
        conditional_t<(N <= 4),  int,
        conditional_t<(N <= 8),  long long,
                                  __int128>>;
    cout << "Size " << N << " → storage is "
         << sizeof(StorageType) << " bytes" << endl;
}

Output:

=== cv-qualifier transformations ===
=== Reference transformations ===
=== Pointer transformations ===
=== decay: the most important transformation ===
=== Conditional type selection ===
BigOrSmall is 64-bit: yes
Size 4 → storage is 4 bytes
Size 8 → storage is 8 bytes
Size 16 → storage is 16 bytes
=== make_signed / make_unsigned ===
=== common_type ===
common_type<int, double>: double
All static_asserts passed — transformations verified!

Step-by-step explanation:

  1. decay_t<T> is the single most used transformation trait. It models “what type does T become when stored as a value?” — stripping references, top-level cv-qualifiers, decaying arrays to pointers and functions to function pointers. auto variable deduction and std::make_pair use decay_t internally.
  2. conditional_t<B, T, F> is the compile-time ternary operator: if B is true, the result is T; otherwise F. Nesting conditional_t implements a compile-time if/else if/else chain for type selection.
  3. common_type_t<T1, T2, ...> finds the type to which all given types can be implicitly converted. Used in generic arithmetic functions: template<typename T, typename U> common_type_t<T,U> add(T a, U b) { return a + b; } — the return type is double if you pass int and double.
  4. make_signed_t / make_unsigned_t convert between signed and unsigned variants of the same integer type. Useful when you have a generic integer type and need to ensure correct signedness for arithmetic or comparison operations.
  5. static_assert(expr) with type traits evaluates the assertion at compile time. If the assertion fails, you get a compile error with the message — zero runtime cost. This is how you write compile-time unit tests for type manipulations.

Relationship Traits: Comparing Types

#include <iostream>
#include <type_traits>
using namespace std;

struct Base { virtual ~Base() = default; };
struct Derived : Base {};
struct Unrelated {};

class NonCopyable {
    NonCopyable(const NonCopyable&) = delete;
public:
    NonCopyable() = default;
};

int main() {
    cout << "=== is_same ===" << endl;
    cout << "is_same<int, int>:        " << is_same_v<int, int>        << endl;
    cout << "is_same<int, const int>:  " << is_same_v<int, const int>  << endl;  // false!
    cout << "is_same<int, signed int>: " << is_same_v<int, signed int> << endl;  // true

    cout << "\n=== is_base_of ===" << endl;
    cout << "is_base_of<Base, Derived>:    " << is_base_of_v<Base, Derived>    << endl;
    cout << "is_base_of<Derived, Base>:    " << is_base_of_v<Derived, Base>    << endl;
    cout << "is_base_of<Base, Base>:       " << is_base_of_v<Base, Base>       << endl;
    cout << "is_base_of<Base, Unrelated>:  " << is_base_of_v<Base, Unrelated>  << endl;

    cout << "\n=== is_convertible ===" << endl;
    cout << "is_convertible<int, double>:    " << is_convertible_v<int, double>    << endl;
    cout << "is_convertible<double, int>:    " << is_convertible_v<double, int>    << endl;
    cout << "is_convertible<Derived*, Base*>:" << is_convertible_v<Derived*, Base*><< endl;
    cout << "is_convertible<Base*, Derived*>:" << is_convertible_v<Base*, Derived*><< endl;
    cout << "is_convertible<int, string>:    " << is_convertible_v<int, string>    << endl;

    cout << "\n=== is_assignable ===" << endl;
    // is_assignable<T, U>: can you do "T t; t = U{}"?
    cout << "is_assignable<int&, int>:         " << is_assignable_v<int&, int>         << endl;
    cout << "is_assignable<int&, double>:      " << is_assignable_v<int&, double>      << endl;
    cout << "is_assignable<int, int>:          " << is_assignable_v<int, int>          << endl;
    cout << "is_assignable<NonCopyable&, NonCopyable>: "
         << is_assignable_v<NonCopyable&, NonCopyable> << endl;

    cout << "\n=== is_invocable (C++17) ===" << endl;
    // is_invocable<F, Args...>: can you call F with Args?
    auto lambda = [](int x, double y) { return x + y; };
    cout << "lambda invocable(int, double):  "
         << is_invocable_v<decltype(lambda), int, double> << endl;
    cout << "lambda invocable(string):       "
         << is_invocable_v<decltype(lambda), string>      << endl;
    cout << "invoke_result<lambda, int, double> is double? "
         << is_same_v<invoke_result_t<decltype(lambda), int, double>, double> << endl;

    return 0;
}

Output:

=== is_same ===
is_same<int, int>:        1
is_same<int, const int>:  0
is_same<int, signed int>: 1

=== is_base_of ===
is_base_of<Base, Derived>:    1
is_base_of<Derived, Base>:    0
is_base_of<Base, Base>:       1
is_base_of<Base, Unrelated>:  0

=== is_convertible ===
is_convertible<int, double>:    1
is_convertible<double, int>:    1
is_convertible<Derived*, Base*>:1
is_convertible<Base*, Derived*>:0
is_convertible<int, string>:    0

=== is_assignable ===
is_assignable<int&, int>:         1
is_assignable<int&, double>:      1
is_assignable<int, int>:          0
is_assignable<NonCopyable&, NonCopyable>: 0

=== is_invocable (C++17) ===
lambda invocable(int, double):  1
lambda invocable(string):       0
invoke_result<lambda, int, double> is double? 1

Step-by-step explanation:

  1. is_same<int, const int> is false — const is part of the type. This surprises many beginners who expect const int to “be” an int. The types are distinct; remove_const_t<const int> produces int.
  2. is_base_of<B, D> is true even when B == D — a class is considered its own base. It is also true for private base classes. Use it to check inheritance relationships in templates.
  3. is_convertible<From, To> checks whether an implicit conversion from From to To exists. double to int is convertible (narrowing, but implicit). Base* to Derived* is not implicitly convertible (requires explicit cast). int to string is not implicitly convertible at all.
  4. is_assignable<T, U> requires T to be an lvalue reference — is_assignable<int, int> is false because you cannot assign to an rvalue int. is_assignable<int&, double> is true because int x; x = 1.5; compiles (narrowing conversion).
  5. invoke_result_t<F, Args...> gives the return type of calling F with Args — the type-safe version of decltype(f(args...)). is_invocable_v checks if the call is valid without actually performing it.

Writing Custom Type Traits

The real power emerges when you write your own type traits for domain-specific questions.

#include <iostream>
#include <type_traits>
#include <vector>
#include <string>
using namespace std;

// Trait 1: Detect if a type has a specific method
// Using void_t idiom (C++17)

// Primary template: no serialize() method
template<typename T, typename = void>
struct has_serialize : false_type {};

// Specialization: T has serialize() returning string
template<typename T>
struct has_serialize<T, void_t<
    decltype(declval<T>().serialize())
>> : is_same<decltype(declval<T>().serialize()), string> {};

template<typename T>
inline constexpr bool has_serialize_v = has_serialize<T>::value;

// Trait 2: Detect if T is a container (has begin/end/size)
template<typename T, typename = void>
struct is_container : false_type {};

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

template<typename T>
inline constexpr bool is_container_v = is_container<T>::value;

// Trait 3: Detect if T supports operator<< with ostream
template<typename T, typename = void>
struct is_streamable : false_type {};

template<typename T>
struct is_streamable<T, void_t<
    decltype(declval<ostream&>() << declval<T>())
>> : true_type {};

template<typename T>
inline constexpr bool is_streamable_v = is_streamable<T>::value;

// Trait 4: Get the element type of a container
template<typename T>
struct element_type { using type = T; };  // Non-container: element is T itself

template<typename T>
struct element_type<vector<T>> { using type = T; };

template<typename T, size_t N>
struct element_type<T[N]> { using type = T; };

template<typename T>
using element_type_t = typename element_type<T>::type;

// Trait 5: A numeric type trait for domain-specific logic
template<typename T>
struct is_numeric : integral_constant<bool,
    is_arithmetic_v<T> && !is_same_v<T, bool> && !is_same_v<T, char>
> {};

template<typename T>
inline constexpr bool is_numeric_v = is_numeric<T>::value;

// --- Test classes ---

struct Serializable {
    int id;
    string name;
    string serialize() const {
        return "id=" + to_string(id) + ";name=" + name;
    }
};

struct NotSerializable {
    int x;
};

struct NotStreamable {
    double privateData;
    // No operator<< defined
};

// --- Generic functions using custom traits ---

template<typename T>
void smartPrint(const T& value) {
    if constexpr (is_streamable_v<T>) {
        cout << "  streamable: " << value << endl;
    } else if constexpr (has_serialize_v<T>) {
        cout << "  serializable: " << value.serialize() << endl;
    } else {
        cout << "  opaque type, size=" << sizeof(T) << " bytes" << endl;
    }
}

template<typename T>
void processContainer(const T& container) {
    static_assert(is_container_v<T>, "T must be a container");
    cout << "Container with " << container.size() << " elements, "
         << "element type size=" << sizeof(element_type_t<T>) << " bytes" << endl;
}

int main() {
    cout << "=== Custom trait detection ===" << endl;

    // has_serialize
    cout << "has_serialize<Serializable>:    " << has_serialize_v<Serializable>    << endl;
    cout << "has_serialize<NotSerializable>: " << has_serialize_v<NotSerializable> << endl;
    cout << "has_serialize<string>:          " << has_serialize_v<string>          << endl;

    // is_container
    cout << "is_container<vector<int>>:  " << is_container_v<vector<int>>  << endl;
    cout << "is_container<string>:       " << is_container_v<string>       << endl;
    cout << "is_container<int>:          " << is_container_v<int>          << endl;

    // is_streamable
    cout << "is_streamable<int>:          " << is_streamable_v<int>          << endl;
    cout << "is_streamable<string>:       " << is_streamable_v<string>       << endl;
    cout << "is_streamable<NotStreamable>:" << is_streamable_v<NotStreamable><< endl;

    // is_numeric
    cout << "is_numeric<int>:    " << is_numeric_v<int>    << endl;
    cout << "is_numeric<double>: " << is_numeric_v<double> << endl;
    cout << "is_numeric<bool>:   " << is_numeric_v<bool>   << endl;  // false
    cout << "is_numeric<char>:   " << is_numeric_v<char>   << endl;  // false

    cout << "\n=== smartPrint with type branching ===" << endl;
    smartPrint(42);                             // streamable: int
    smartPrint(string("hello"));                // streamable: string
    smartPrint(Serializable{1, "Alice"});        // serializable: no operator<<
    smartPrint(NotStreamable{3.14});            // opaque

    cout << "\n=== processContainer ===" << endl;
    vector<double> dv = {1.1, 2.2, 3.3};
    string s = "hello";
    processContainer(dv);
    processContainer(s);

    cout << "\n=== element_type ===" << endl;
    cout << "element_type_t<vector<int>>: int? "
         << is_same_v<element_type_t<vector<int>>, int> << endl;
    cout << "element_type_t<double[10]>: double? "
         << is_same_v<element_type_t<double[10]>, double> << endl;

    return 0;
}

Output:

=== Custom trait detection ===
has_serialize<Serializable>:    1
has_serialize<NotSerializable>: 0
has_serialize<string>:          0

is_container<vector<int>>:  1
is_container<string>:       1
is_container<int>:          0

is_streamable<int>:          1
is_streamable<string>:       1
is_streamable<NotStreamable>:0

is_numeric<int>:    1
is_numeric<double>: 1
is_numeric<bool>:   0
is_numeric<char>:   0

=== smartPrint with type branching ===
  streamable: 42
  streamable: hello
  serializable: id=1;name=Alice
  opaque type, size=8 bytes

=== processContainer ===
Container with 3 elements, element type size=8 bytes
Container with 5 elements, element type size=1 bytes

=== element_type ===
element_type_t<vector<int>>: int? 1
element_type_t<double[10]>: double? 1

Step-by-step explanation:

  1. void_t idiom is the key technique for detecting methods. void_t<expr> is void if expr is valid, and causes substitution failure (SFINAE) if expr is invalid. The specialization struct has_serialize<T, void_t<decltype(declval<T>().serialize())>> only exists when T::serialize() is valid.
  2. declval<T>() produces an rvalue of type T without constructing one — usable in unevaluated contexts like decltype. This is how you “call” a method on a type without an actual object.
  3. smartPrint uses if constexpr to branch on trait values. Each branch is compiled only if its condition is true — so the branch for serializable (which calls .serialize()) is not compiled for types like int that don’t have that method.
  4. is_numeric<T> shows trait composition: build a new trait from existing ones using integral_constant. This excludes bool and char from “numeric” even though they are technically integral — a domain-specific decision appropriate for many mathematical APIs.
  5. The void_t approach is C++17 standard. In C++20, Concepts provide a cleaner syntax for the same detection, but void_t traits remain useful for backward compatibility and library code.

Type Traits with if constexpr: Compile-Time Branching

The combination of type traits and if constexpr (C++17) is one of the most useful patterns in modern generic programming:

#include <iostream>
#include <type_traits>
#include <string>
#include <vector>
using namespace std;

// Generic to_string that handles any type sensibly
template<typename T>
string universalToString(const T& value) {
    if constexpr (is_same_v<T, string>) {
        return value;                          // Already a string
    } else if constexpr (is_arithmetic_v<T>) {
        return std::to_string(value);          // Numbers: use std::to_string
    } else if constexpr (is_pointer_v<T>) {
        if (value == nullptr) return "nullptr";
        ostringstream oss;
        oss << "0x" << hex << reinterpret_cast<uintptr_t>(value);
        return oss.str();                      // Pointers: hex address
    } else if constexpr (is_array_v<T>) {
        string result = "[";
        for (size_t i = 0; i < extent_v<T>; i++) {
            if (i > 0) result += ", ";
            result += universalToString(value[i]);
        }
        return result + "]";
    } else {
        return "{object of size " + std::to_string(sizeof(T)) + "}";
    }
}

// Generic copy: uses memcpy for trivially copyable types (fast path)
template<typename T>
void genericCopy(T* dest, const T* src, size_t count) {
    if constexpr (is_trivially_copyable_v<T>) {
        // Fast path: trivially copyable, use memcpy
        memcpy(dest, src, count * sizeof(T));
        cout << "  Using memcpy for trivially copyable type" << endl;
    } else {
        // Slow path: call copy constructor for each element
        for (size_t i = 0; i < count; i++) {
            new (dest + i) T(src[i]);
        }
        cout << "  Using copy constructor for non-trivial type" << endl;
    }
}

// Generic absolute value that handles signed/unsigned correctly
template<typename T>
T genericAbs(T value) {
    if constexpr (is_unsigned_v<T>) {
        return value;  // Unsigned: always non-negative
    } else if constexpr (is_floating_point_v<T>) {
        return value < 0 ? -value : value;
    } else {
        // Signed integer: handle INT_MIN carefully
        return value < 0 ? -value : value;
    }
}

struct ComplexObject {
    string name;
    vector<int> data;
    ComplexObject(string n, vector<int> d) : name(n), data(d) {}
    ComplexObject(const ComplexObject&) = default;
};

int main() {
    cout << "=== universalToString ===" << endl;
    cout << universalToString(42)          << endl;
    cout << universalToString(3.14)        << endl;
    cout << universalToString(string("hi"))<< endl;
    int x = 5;
    cout << universalToString(&x)          << endl;
    cout << universalToString((int*)nullptr)<< endl;
    int arr[] = {1, 2, 3, 4, 5};
    cout << universalToString(arr)         << endl;
    cout << universalToString(ComplexObject{"test", {}})<< endl;

    cout << "\n=== genericCopy ===" << endl;
    // Trivially copyable: uses memcpy
    int src[5] = {1, 2, 3, 4, 5};
    int dst[5];
    genericCopy(dst, src, 5);
    cout << "  Copied: ";
    for (int v : dst) cout << v << " ";
    cout << endl;

    // Non-trivially copyable: uses copy constructor
    ComplexObject srcObj("Alice", {10, 20, 30});
    alignas(ComplexObject) char dstBuf[sizeof(ComplexObject)];
    ComplexObject* dstObj = reinterpret_cast<ComplexObject*>(dstBuf);
    genericCopy(dstObj, &srcObj, 1);
    cout << "  Copied name: " << dstObj->name << endl;
    dstObj->~ComplexObject();

    cout << "\n=== genericAbs ===" << endl;
    cout << "abs(-5):         " << genericAbs(-5)         << endl;
    cout << "abs(-3.14):      " << genericAbs(-3.14)      << endl;
    cout << "abs(5u):         " << genericAbs(5u)         << endl;

    return 0;
}

Output:

=== universalToString ===
42
3.140000
hi
0x7ffd...
nullptr
[1, 2, 3, 4, 5]
{object of size 56}

=== genericCopy ===
  Using memcpy for trivially copyable type
  Copied: 1 2 3 4 5
  Using copy constructor for non-trivial type
  Copied name: Alice

=== genericAbs ===
abs(-5):         5
abs(-3.14):      3.14
abs(5u):         5

Step-by-step explanation:

  1. Each if constexpr branch is only instantiated if its condition is true. For universalToString(42), the is_arithmetic_v<T> branch is the one compiled — the is_array_v<T> branch, which accesses extent_v<T>, is not compiled (and would fail for int).
  2. genericCopy demonstrates the classic optimization: is_trivially_copyable_v<T> selects memcpy (one fast hardware instruction) for plain data types, and the copy constructor loop for complex types. This is exactly how std::copy is optimized in standard library implementations.
  3. The if constexpr branches can access type-specific operations without breaking compilation for other types. extent_v<T> (the array size) is only computed in the is_array_v<T> branch, where it is valid.
  4. genericAbs with if constexpr avoids applying the negation -value to unsigned types, which would produce unexpected large values. The unsigned branch return value is zero-cost — compiles to nothing.

Type Traits Quick Reference

Category Trait What it checks / produces
Primary is_integral_v<T> int, char, bool, etc.
Primary is_floating_point_v<T> float, double, long double
Primary is_pointer_v<T> T* or const T*
Primary is_array_v<T> T[N] or T[]
Primary is_class_v<T> struct or class type
Composite is_arithmetic_v<T> integral or floating point
Composite is_scalar_v<T> arithmetic, pointer, enum, nullptr_t
Property is_const_v<T> top-level const qualifier
Property is_trivially_copyable_v<T> safe to memcpy
Property is_polymorphic_v<T> has virtual functions
Property is_empty_v<T> no non-static data members
Operation is_constructible_v<T, Args...> T(args) is valid
Operation is_copy_constructible_v<T> T(const T&) is valid
Operation is_nothrow_move_constructible_v<T> noexcept move ctor
Operation is_invocable_v<F, Args...> f(args) is valid
Relation is_same_v<T, U> T and U are identical
Relation is_base_of_v<B, D> B is base class of D
Relation is_convertible_v<From, To> implicit conversion exists
Transform remove_const_t<T> strips top-level const
Transform remove_reference_t<T> strips & or &&
Transform decay_t<T> models pass-by-value
Transform conditional_t<B, T, F> B ? T : F at compile time
Transform common_type_t<T, U> common convertible type
Size alignment_of_v<T> alignment requirement
Size rank_v<T> number of array dimensions

Conclusion

Type traits are the compile-time reflection system of C++. They answer questions about types, transform types into related types, and compare types — all at compile time, with zero runtime cost. Understanding them unlocks the full power of generic programming: instead of writing separate functions for int vs double vs string, you write one function that uses type traits to select the correct behavior for each type automatically.

The standard library’s <type_traits> provides over 100 traits covering every aspect of the C++ type system: primary categories, composite categories, cv-qualifiers, references, pointers, special member functions, constructibility, and relationships between types. The C++17 _v variable templates and C++14 _t type aliases make these traits readable and ergonomic.

Custom type traits — built with the void_t idiom and template specialization — let you define your own compile-time predicates: does this type have a .serialize() method? Is it a container? Does it support streaming? These power the if constexpr branches and SFINAE constraints that make generic libraries both flexible and safe.

Together with if constexpr, SFINAE, and C++20 Concepts, type traits form the backbone of modern C++ template programming — enabling code that adapts to type properties at compile time, generating optimal machine code for each type without any runtime dispatch overhead.

Hot this week

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Coroutines in C++20: Asynchronous Programming

Master C++20 coroutines — learn co_await, co_yield, co_return, promise types, awaitables, generators, and how to build async tasks and lazy sequences without callback hell.

Topics

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Coroutines in C++20: Asynchronous Programming

Master C++20 coroutines — learn co_await, co_yield, co_return, promise types, awaitables, generators, and how to build async tasks and lazy sequences without callback hell.

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

std::optional, std::variant, and std::any in Modern C++

Master C++17's three vocabulary types — std::optional for nullable values, std::variant for type-safe unions, and std::any for type-erased storage, with practical examples and best practices.

C++17 Structured Bindings: Unpacking Data

Master C++17 structured bindings — learn how to unpack tuples, pairs, arrays, and structs into named variables, use them in range-for loops, and extend them to custom types.

Related Articles

Popular Categories