Type Traits: Compile-Time Type Analysis

Learn how to use type traits to perform compile-time type analysis, enable conditional compilation, and enforce type requirements in templates.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated

Type traits are a feature of C++ that allows us to perform compile-time analysis of the types we use in our code. In this lesson, we’ll introduce type traits, and then cover examples of common use cases, including:

  • Template type safety and documentation: ensuring that template functions are only called with the expected types, thereby preventing runtime errors, and improving the developer experience
  • Compile-Time if statements: enabling or disabling lines of code based on the properties of a type, or other compile-time factors
  • Template metaprogramming: writing code to change how templates are generated and used at compile time
  • Conditional type selection: changing the data types we use based on boolean expressions
  • Custom types - how our own user-defined types can interact with any of these systems

Preview: Static Members

Type traits rely heavily on aliases and static members that exist on structs and classes. Unlike a regular member variable, a static variable is owned by the class itself, rather than the objects of that class.

In the following example, we have a type SomeStruct which defines a type alias called type which alises bool, and a static member called value, which is the integer 42. We can then access those members outside of the class, using the scope resolution operator ::

struct SomeStruct {
  using type = bool;
  static const int value{42};
};

int main() {
  // This will be a bool
  SomeStruct::type MyVariableA;

  // This will have a value of 42
  int MyVariableB = SomeStruct::value;
}

Using Type Traits

The C++ standard library includes a large collection of type traits, and related functions, within the <type_traits> header file

#include <type_traits>

Type traits are within the std namespace, and almost all of them are struct templates. They accept the type we want to investigate as a template parameter and make the result of that investigation available through a static value field.

For example, the std::is_arithmetic type trait tells us if a type is numeric:

#include <type_traits>
#include <iostream>

int main() {
  if (std::is_arithmetic<int>::value) {
    std::cout << "int is arithmetic";
  }
  if (!std::is_arithmetic<std::string>::value) {
    std::cout << "\nbut std::string isn't";
  }
}
int is arithmetic
but std::string isn't

From C++17 onwards, we have a more concise way to access the value field of standard library traits. We can instead append _v to the class name or, less commonly, we can use the () operator:

#include <type_traits>
#include <iostream>

int main() {
  if (std::is_arithmetic_v<double>) {
    std::cout << "double is arithmetic\\n";
  }
  if (std::is_arithmetic<std::int32_t>()) {
    std::cout << "int32_t is also arithmetic";
  }
}
double is arithmetic
int32_t is also arithmetic

Type Traits with Templates

More usefully, we’d use type traits in a situation where we don’t necessarily know what type we’re dealing with. This means type traits are most commonly used when working with templates:

#include <type_traits>
#include <iostream>

template <typename T>
void Function(T Param) {
  if (std::is_arithmetic_v<T>) {
    std::cout << Param << " is arithmetic\n";
  } else {
    std::cout << Param << " is not arithmetic\n";
  }
}

int main() {
  Function(42);
  Function("Hello World");
}
42 is arithmetic
Hello World is not arithmetic

Using if constexpr Statements

The logic we implement with type traits is evaluated at compile time. For example, a statement like std::is_arithmetic_v<T> will be determined at compile time, and the result of that expression will then be available at run time.

For example, imagine we have the following code:

#include <type_traits>
#include <iostream>

template <typename T>
void LogDouble(T Param) {
  if (std::is_arithmetic_v<T>) {
    std::cout << "Double: " << Param * 2;
  }
}

int main() {
  LogDouble(42);
  LogDouble(std::string("Hello World"));
}

After our templates are instantiated and our type trait evaluated, we can imagine we have two functions, one instantiated when T is an int, and one instantiated when T is a std::string:

void LogDouble(int Param) {
  if (true) {
    std::cout << "Double: " << Param * 2;
  }
}
void LogDouble(std::string Param) {
  if (false) {
    std::cout << "Double: " << Param * 2;
  }
}

This is a problem because std::string does not implement the * operator, so we get a compilation error even though that statement was within an if (false) block:

error: binary '*': 'T' does not define this operator or a conversion to a type acceptable to the predefined operator

To address this, C++17 introduced if constexpr statements. These are evaluated at compile time and, if the expression we pass to if constexpr evaluates to false, the block of code is removed from our function entirely.

Let’s apply it to our previous example:

#include <iostream>
#include <type_traits>

template <typename T>
void LogDouble(T Param) {
  if constexpr (std::is_arithmetic_v<T>) {  
    std::cout << "Double: " << Param * 2;
  }
}

int main() {
  LogDouble(42);
  LogDouble(std::string("Hello World"));
}

Now, we can imagine our instantiated functions look like this:

void LogDouble(int Param) {
  std::cout << "Double: " << Param * 2;
}

void LogDouble(std::string Param) {}

There’s no longer any compiler error here, so our program runs successfully and outputs:

Double: 84

More Standard Library Examples

Below, we show more examples of if constexpr, and some more useful standard library type traits:

  • std::is_pointer lets us determine if a type is a pointer
  • std::is_class lets us determine if a type is a class or struct, excluding enums and unions (which we cover later in the course)
  • std::is_same lets us determine if a type is the same as another type
  • std::is_base_of lets us determine if a type is the same as another type, or derived from that type through inheritance
#include <iostream>
#include <type_traits>

class Actor {};
class Monster : Actor {};

template <typename T>
void Function(T Param) {
  if constexpr (std::is_pointer_v<T>) {  
    std::cout << "\nType is a pointer";
  }

  if constexpr (std::is_class_v<T>) {  
    std::cout << "\nType is a class";
  }

  if constexpr (std::is_same_v<Actor, T>) {  
    std::cout << "\nType is an Actor";
  }
  
  if constexpr (
    std::is_base_of_v<Actor, T>) {  
    std::cout << "\nType is derived from Actor";
  }
}

int main() {
  std::cout << "&x: ";
  int x{5};
  Function(&x);

  std::cout << "\n\nMyActor: ";
  Actor MyActor;
  Function(MyActor);

  std::cout << "\n\nMyActor: ";
  Monster MyMonster;
  Function(MyMonster);
}
&x:
Type is a pointer

MyActor:
Type is a class
Type is an Actor
Type is derived from Actor

MyActor:
Type is a class
Type is derived from Actor

For a list of all traits that are available within the standard library, there are many references we can use like cppreference.com

Using static_assert()

One of the main uses for type traits is to ensure a provided type meets the expectations of our template. For example, let’s imagine we created this simple template function.

template <typename T1, typename T2>
auto Average(T1 x, T2 y) {
  return (x + y) / 2;
}

This function allows us to pass two numeric values, and get their average. Because the types are templated, we can pass any type to this function.

However, that’s a bit too permissive. Our function is intended to work with numbers, but that is not made explicit. What if another type is passed to this function? One of two things can happen.

In the best-case scenario, the type will not support one of the operators we’re using in the function, such as +. This will result in a compiler error, although for someone not familiar with our template, it doesn’t describe the core problem:

template <typename T1, typename T2>
auto Average(T1 x, T2 y) {
  return (x + y) / 2;
}

int main() {
  Average("Hello", "World");
}
error C2110: '+': cannot add two pointers

In the worst case, the type we pass does implement all the required operators, but the use case for those operators is not the same as what our function is assuming. Types are free to overload the + and / operators for any purpose - they don’t need to mean addition and division.

Type traits allow us to be more explicit about the requirements of our types. The most basic example of this is simply using a static_assert() to ensure the type meets the expected criteria:

#include <type_traits>

template <typename T1, typename T2>
auto Average(T1 x, T2 y) {
  static_assert(std::is_arithmetic_v<T1>
    && std::is_arithmetic_v<T2>,
    "Average: Arguments must be numeric");

  return (x + y) / 2;
}

int main() {
  Average("Hello", "World");
}
error: static_assert failed: 'Average: Arguments must be numeric'

This has three benefits:

  • It implements the desired requirements. Now, the compiler will ensure that the provided type is a number - not just that it implements the + and / operators.
  • It documents our requirements. Anyone looking at our Average function will be able to see what the requirements are. Some IDEs will also be able to better understand the requirements of this function, and provide immediate feedback as we’re writing the code
  • If someone tries to use our template with a type that doesn’t meet the requirements, we get to provide the error message, explaining the intended usage.

Comparing Types

One of the main use cases we have for type traits is to determine if some template type T matches a specific type. Below, our template has a different implementation depending on whether it was instantiated with an int or not:

#include <iostream>
#include <type_traits>

template <typename T>
void Print(T&& x) {
  if constexpr (std::is_same_v<T, int>) {
    std::cout << x << " is an int\n";
  } else {
    std::cout << x << " is not an int\n";
  }
}

int main() {
  Print(1);
}
1 is an int

Preview: Forwarding References

The previous example uses a forwarding reference, denoted by the double ampersand && prepended to a template type. We’re using a forwarding reference here because it can capture both lvalue and rvalue references.

Further nuance isn’t important for this introduction to type traits, but we cover forwarding references in detail later in the course:

The comparisons carried out by these type traits are often more strict than we need. For example, if our type is a const int, the non-int code path will be used, because a const int is not the same as an int.

Similar distinctions are made between value categories and reference types, further disrupting our intended design:

#include <type_traits>
#include <iostream>

template <typename T>
void Print(T&& x) {
  if constexpr (std::is_same_v<T, int>) {
    std::cout << x << " is an int\n";
  } else {
    std::cout << x << " is not an int\n";
  }
}

int main() {
  Print(1);

  int y{2};
  Print(y);

  const int x{3};
  Print(x);
}
1 is an int
2 is not an int
3 is not an int

Often, the logic we’re trying to implement doesn’t care whether our types are constant, or whether they’re references. To implement these agnostic comparisons, the standard library includes further traits, such as std::remove_const and std::remove_reference.

We pass a type to these traits, and they return a type that is possibly different. The returned type will be the same as the type we provide but with any const or reference aspect removed.

The new type is available as the ::type static member, or by appending _t to the struct name:

#include <iostream>
#include <type_traits>

int main() {
  if constexpr (std::is_same_v<int,
    std::remove_const<const int>::type>) {
    std::cout << "Those are the same";
  }

  if constexpr (std::is_same_v<int,
    std::remove_reference_t<int&>>) {
    std::cout << "\nThose are the same too";
  }
}
Those are the same
Those are the same too

We can remove both const and references at once using std::remove_cvref:

#include <iostream>
#include <type_traits>

int main() {
  if constexpr (std::is_same_v<int,
    std::remove_cvref_t<const int&>>) {
    std::cout << "Those are the same";
  }
}
Those are the same

Volatile Types

The "cvref" in std::remove_cvref is an abbreviation for const, volatile and reference.

A volatile variable is one that can be modified from outside of our program. For example, we may have some external program or hardware device writing to the specific memory location that our variable maps to.

If the compiler sees our code isn’t changing a variable, it might assume the variable isn’t changing. It might therefore implement a performance optimization that avoids reading the memory location at all.

By marking the variable as volotile, we tell the compiler that the value can change in ways it is not aware of. As such, it should avoid doing those optimizations and always get the latest value from memory.

volatile int SomeInteger{42};

Just as we can remove reference and const qualifiers from types using std::remove_reference and std::remove_const, we can also remove volatile using std::remove_volatile:

#include <iostream>
#include <type_traits>

int main() {
  if constexpr (std::is_same_v<int,
    std::remove_volatile_t<volatile int>>) {
    std::cout << "Those are the same";
  }
}
Those are the same

And we can remove all three using std::remove_cvref.

Let’s apply this to fix the problem we had with our template. We create a type alias BaseType using the type returned from std::remove_cvref_t:

#include <iostream>
#include <type_traits>

template <typename T>
void Print(T&& x) {
  using BaseType = std::remove_cvref_t<T>;       

  if constexpr (std::is_same_v<BaseType, int>) {  
    std::cout << x << " is an int\n";
  } else {
    std::cout << x << " is not an int\n";
  }
}

int main() {
  Print(1);

  int y{2};
  Print(y);

  const int x{3};
  Print(x);
}
1 is an int
2 is an int
3 is an int

Note that std::remove_cvref and similar type traits are not modifying our function parameter. For example, if x is const, it continues to be const in our previous example.

Type traits like std::remove_cvref simply receive a type as an argument, and return a type that does not have those characteristics.

Creating our own Type Traits

As usual, we’re not restricted to just the type traits that come with the standard library. We can pull traits in from third-party libraries or create our own. Let's imagine we have a template function that receives objects that may or may not have a Render function.

We’d like to write an is_renderable trait to help us out:

template <typename T>
void Render(T Param) {
  if constexpr (is_renderable<T>::value) {
    Param.Render();
  } else {
    std::cout << "\nNot Renderable";
  }
}

Standard library-type traits that answer questions like this are struct templates that accept a type as a template argument, and make the boolean result available as a value static member. We can do the same and, in general, we’d want our type trait’s value to be false:

template <typename T>
struct is_renderable {
  static const bool value{false};
};

Now, for any type we create that does satisfy the requirement, we can specialize the type trait template to make the value true. In the following code, is_renderable<T>::value will be true if T is Fish, and false for any other type:

#include <iostream>

// General Template
template <typename T>
struct is_renderable {
  static const bool value{false};
};

class Fish {
 public:
  void Render() {
    std::cout << "Fish: ><((((`>";
  }
};

// Specialized Template
template<>
struct is_renderable<Fish> {
  static const bool value{true};
};

void Render(T Param) {/*...*/} int main() { Fish MyFish; Render(MyFish); // Renderable Render(42); // Not Renderable }
Fish: ><((((`>
Not Renderable

std::false_type and std::true_type

The standard library’s <type_traits> header includes a pair of utilities that make type traits slightly faster to implement. Rather than defining our value static members, we can just inherit them from a base struct.

Our struct can inherit from std::false_type in the general case (when we want value to be false) and std::true_type when specializing the type trait to set value to true.

// General Template
template <typename>
struct MyTrait : std::false_type {};

// Specialized Template
template <>
struct MyTrait<SomeType> : std::true_type {};

Below, we’ve updated our previous is_renderable example to use this technique:

#include <iostream>
#include <type_traits>

// General Template
template <typename>
struct is_renderable : std::false_type {};

class Fish {/*...*/}; // Specialized Template template <> struct is_renderable<Fish> : std::true_type {};
void Render(T Param) {/*...*/}
int main() {/*...*/}
Fish: ><((((`>
Not Renderable

Implementing the _v and _t Pattern

As we’ve seen, the standard library’s type traits make their results available through variables suffixed with _v (for values) and _t (for types). For example, std::is_integral_v<T> is equivalent to std::is_integral<T>::value.

We can implement the same API for our own type traits, using a variable template that retrieves the correct static member from our struct:

#include <iostream>
#include <type_traits>

struct is_renderable {/*...*/}; template <typename T> constexpr bool is_renderable_v{ is_renderable<T>::value};
class Fish {/*...*/};
struct is_renderable<Fish> {/*...*/}; template <typename T> void Render(T Param) { if constexpr (is_renderable_v<T>) { Param.Render(); } else { std::cout << "\nNot Renderable"; } }
int main() {/*...*/}
Fish: ><((((`>
Not Renderable

Making Custom Types Interact with Standard Library Type Traits

A common question is whether we can make our custom types interact with standard library type traits in the same way as the built-in fundamental types, such as int.

For example, we may have created our own numeric type, and would like objects such as std::is_arithmetic_t to return true for our type.

This may be possible but is generally not recommended. The C++ specification does not define what should happen when we try this.

It may work in some compilers, but it may not work in others. So, it’s not something we should try to do - we should instead find an alternative way to implement our required behavior.

Summary

In this lesson, we introduced type traits in C++ and explored their common use cases. We learned how to use type traits from the <type_traits> header to perform compile-time analysis of types. Key takeaways:

  • Type traits allow compile-time analysis of types
  • if constexpr enables conditional compilation based on type traits
  • static_assert can enforce type requirements in templates
  • Custom type traits can be created using struct templates and specialization
  • Type traits improve template safety, documentation, and error messages

Was this lesson useful?

Next Lesson

Using SFINAE to Control Overload Resolution

Learn how SFINAE allows templates to remove themselves from overload resolution based on their arguments, and apply it in practical examples.
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Updated
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Type Traits and Concepts
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, unlimited access

This course includes:

  • 124 Lessons
  • 550+ Code Samples
  • 96% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Using SFINAE to Control Overload Resolution

Learn how SFINAE allows templates to remove themselves from overload resolution based on their arguments, and apply it in practical examples.
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved