Type traits are small templates in the <type_traits> header that answer questions about types at compile time. They report whether a type is an integer, whether it's a pointer, whether two types are the same, and they can transform one type into another, all without running any code. Modern C++ generics lean on them constantly: std::vector, std::sort, and std::optional all read type traits internally to pick the right implementation for whichever type is passed.
A type trait is a template, usually a struct, whose specializations encode a fact about its type argument. The simplest ones expose a static value member that's either true or false. Others expose a nested type member that returns a transformed type.
std::is_integral<int>::value is a compile-time constant. The compiler resolves it during template instantiation, not at runtime, so it costs nothing in the generated binary. Reading the value member is reading a static constexpr bool.
The point of having these traits is to make generic code smarter about the types it sees. Consider a Cart<T> that holds items and should refuse template instantiations where T is something nonsensical like a function pointer or a void. Without type traits, there's no way to express that check in code. With them, it's one line.
Type trait queries are evaluated entirely at compile time. They add zero runtime work and no extra code to the binary. The only price is slightly slower compile times when many of them stack in deeply nested templates.
Every C++ type belongs to exactly one primary category. The traits for these categories are the building blocks for almost everything else. The most common ones in everyday code:
| Trait | True when T is |
|---|---|
std::is_integral<T> | An integer type (int, long, char, bool, etc.) |
std::is_floating_point<T> | float, double, or long double |
std::is_pointer<T> | A raw pointer (T*), not including references or smart pointers |
std::is_reference<T> | An lvalue or rvalue reference (T& or T&&) |
std::is_class<T> | A class or struct type |
std::is_enum<T> | An enum (scoped or unscoped) |
std::is_array<T> | A C-style array (T[N]), not std::array |
std::is_function<T> | A function type (rare; usually pointers to functions) |
std::is_void<T> | The void type |
A walkthrough showing several at once on e-commerce types.
A type belongs to exactly one primary category. int* is a pointer, not also an integral type. std::string is a class type, neither integral nor an array even though it can hold characters. The category is about the type itself, not what it represents.
This taxonomy comes straight from the C++ standard. std::is_pointer<T> doesn't trigger for std::unique_ptr<T> or std::shared_ptr<T>: those are class types, not raw pointers. For "any pointer-like thing", a custom trait is needed.
The primary categories are mutually exclusive, but the standard also ships composite traits that bundle them. These are the ones most often used when constraining generic code.
| Trait | True when |
|---|---|
std::is_arithmetic<T> | T is integral or floating point |
std::is_fundamental<T> | Arithmetic, or void, or nullptr_t |
std::is_object<T> | Anything except references, functions, or void |
std::is_scalar<T> | Arithmetic, pointer, enum, member pointer, or nullptr_t |
std::is_compound<T> | Anything that's not fundamental |
is_arithmetic is the most commonly used. It's how the standard library says "must be a number".
is_arithmetic is std::is_integral<T>::value || std::is_floating_point<T>::value packaged into one trait. Use the composite when the question is "is it any kind of number?". Use the primary trait only when the specific kind matters.
A worked example showing where each composite lights up.
References aren't "objects" in the type-trait sense. The standard calls something an "object type" when it can be stored, copied, and have its address taken. A reference can't be reassigned to refer to something else, so it's not an object type.
Beyond categories, traits can ask about properties layered on top of a type: is it const? Is it volatile? Is it signed? These are the second tier of common questions.
| Trait | True when |
|---|---|
std::is_const<T> | T is const-qualified |
std::is_volatile<T> | T is volatile-qualified |
std::is_signed<T> | T is a signed arithmetic type |
std::is_unsigned<T> | T is an unsigned arithmetic type |
std::is_empty<T> | T is a class with no non-static data members |
std::is_polymorphic<T> | T is a class with at least one virtual function |
std::is_abstract<T> | T is an abstract class (has a pure virtual function) |
std::is_trivial<T> | T is trivially constructible and copyable |
A small demo:
One detail worth knowing: std::is_const<int* const> is true (the pointer is const), but std::is_const<const int*> is false (the pointee is const, not the pointer). Type traits look at the top level of the type only. Anything past a layer of indirection is its own type.
Another family of traits asks "can this operation be performed with this type?". These are how libraries like std::vector decide between calling a move constructor and falling back to copy, or whether they can use an optimized memcpy path for a trivial type.
| Trait | True when |
|---|---|
std::is_default_constructible<T> | T() is well-formed |
std::is_copy_constructible<T> | T(const T&) is well-formed |
std::is_move_constructible<T> | T(T&&) is well-formed |
std::is_copy_assignable<T> | T& operator=(const T&) is well-formed |
std::is_move_assignable<T> | T& operator=(T&&) is well-formed |
std::is_destructible<T> | ~T() is well-formed |
std::is_invocable<F, Args...> | F can be called with the given args |
std::is_constructible<T, Args...> | T(Args...) is well-formed |
A practical e-commerce illustration. A Customer type is intentionally non-copyable because each customer must have a unique identity.
This is useful for generic code that must behave correctly for both copyable and move-only types. The standard library uses traits like these constantly. std::vector::push_back, for instance, calls move when is_nothrow_move_constructible is true and falls back to copy otherwise, because it can only guarantee the strong exception safety it promises if the move can't throw.
Querying constructibility traits forces the compiler to do real overload resolution as if it were trying to call the constructor. For a class with many overloads or complex template constructors, this isn't free. Heavy use in tight templates can slow compile times noticeably.
Two-type traits ask how a pair of types relate. These are heavily used in template machinery for inheritance hierarchies and conversion logic.
| Trait | True when |
|---|---|
std::is_same<T, U> | T and U are exactly the same type |
std::is_base_of<Base, Derived> | Base is a base class of Derived |
std::is_convertible<From, To> | A value of From is implicitly convertible to To |
A small illustration with an order hierarchy. Online orders inherit from a generic order.
std::is_same is strict. It treats int and const int as different types, and treats int and long as different even though they often share the same size. For "same up to const-ness", strip const first with std::remove_cv.
std::is_base_of reports true for a type compared to itself. That is, std::is_base_of<Order, Order>::value is true. The standard reads "is Base a base of Derived (or the same type)". Worth remembering.
Until now, every trait has been a question with a true/false answer. The other half of <type_traits> is transformations: traits that take a type and return a related type. These are written as structs with a nested ::type member.
| Transformation | Result |
|---|---|
std::remove_const<T> | T without top-level const |
std::remove_volatile<T> | T without top-level volatile |
std::remove_cv<T> | T without const or volatile |
std::remove_reference<T> | T without & or && |
std::remove_pointer<T> | What T points to, if T is a pointer |
std::add_const<T> | T with top-level const added |
std::add_pointer<T> | T* |
std::decay<T> | T after the conversions a function-by-value parameter would apply |
std::conditional<B, T, F> | T if B is true, else F |
decay does a lot of work and deserves attention. When an argument is passed by value to a function, the compiler strips top-level references, strips top-level cv-qualifiers, decays arrays to pointers, and decays functions to function pointers. std::decay exposes that same set of transformations.
std::conditional is a compile-time ternary on types. It's how generic code picks between two type alternatives based on a condition.
The BestStorage alias picks T directly when T fits in a pointer-sized slot, and falls back to T* otherwise. Generic library code makes decisions like this routinely.
The pattern is consistent across all transformations. Feed in a type, read off ::type, get back the transformed result. Composing them allows building up sophisticated type calculations.
typename std::is_integral<T>::value and typename std::remove_reference<T>::type are clunky to type. C++14 added _t aliases for transformations, and C++17 added _v variable templates for boolean traits, specifically to clean this up.
| Old form (C++11) | Modern form |
|---|---|
std::is_integral<T>::value | std::is_integral_v<T> |
std::is_same<T, U>::value | std::is_same_v<T, U> |
typename std::remove_reference<T>::type | std::remove_reference_t<T> |
typename std::decay<T>::type | std::decay_t<T> |
typename std::conditional<B, T, F>::type | std::conditional_t<B, T, F> |
The same logic, shorter to type and read.
Modern code uses _v and _t almost exclusively. The longer ::value and ::type forms still work everywhere, but they mostly appear in code older than C++17 or in places where the author is being deliberately portable to older compilers.
std::enable_if is the bridge from "can a question be asked about a type?" to "can an overload be chosen based on the answer?". The short version of how traits feed into it.
The return type, std::enable_if_t<std::is_arithmetic_v<T>, T>, only resolves to T when is_arithmetic_v<T> is true. For any other type, the substitution fails and the overload is removed from the candidate set. Calling applyTaxRate("free") produces a clear compile error saying no matching function was found.
This pairing of "trait query" with "enable_if gating" was the dominant way to constrain templates before C++20.
Sometimes the standard doesn't have the needed trait. Custom traits use a two-template pattern: a primary template that inherits from std::false_type by default, and a specialization that inherits from std::true_type for the case being detected.
A custom trait that detects whether a type is a std::vector of anything:
std::true_type and std::false_type are shortcuts for "this trait's value is true/false". Inheriting from them provides the ::value member. The custom trait then works exactly like a standard library trait.
A second example: a trait that checks whether a type has a name member of type std::string. The detection pattern comes from the SFINAE lesson, applied here to a fresh case.
The mechanics here (the std::void_t trick, std::declval) come straight from the SFINAE lesson. The pattern is general: define a primary template that says "no", and a specialization that matches only when the expression being checked is well-formed.
Concepts in C++20 take the same job that enable_if + type traits did and make it cleaner. Instead of std::enable_if_t<std::is_arithmetic_v<T>, T> in a return type, use template<std::integral T> or template<typename T> requires std::is_arithmetic_v<T>, and the constraint sits where it's expected.
The traits themselves haven't gone anywhere. std::is_integral, std::is_same, std::remove_reference, and the rest are still the workhorse mechanisms underneath. Concepts mostly change how they're applied at the function signature level. Type traits remain the only way to:
if constexpr).For C++20 or newer, prefer concepts for constraining template parameters at the signature. Keep using type traits for everything else.
10 quizzes