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.

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

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\nMyMonster: ";
  Monster MyMonster;
  Function(MyMonster);
}
&x:
Type is a pointer

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

MyMonster:
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 a different, unexpected 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 not in the way that our template is assuming. Types are free to implement operators like + and / in any way they want - they don't need to represent addition and division.

As such, our template will accept those objects without any compilation error, resulting in unexpected behaviour at run time.

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 a custom, contextual error message.

Comparing Types

One of the main use cases we have for type traits is to determine if some template typename 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

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

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 follow the same convention. We'd initially want our type trait's value to be false, meaning any arbitrary object does not satisfy our type trait by default:

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

Template Specialization

A practical guide to template specialization in C++ covering full and partial specialization, and the scenarios where they're useful

Using 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

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
Next Lesson
Lesson 30 of 128

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.

Questions & Answers

Answers are generated by AI models and may not have been reviewed. Be mindful when running any code on your device.

Using std::is_base_of with Template Types
How can I use std::is_base_of to check if a template type is derived from a specific base class?
Creating a Custom Type Trait to Check for a Member Function
How can I create a custom type trait to check if a type has a specific member function?
Using Type Traits with Class Templates
Can I use type traits to conditionally enable or disable certain member functions in a class template based on the template type?
Using Type Traits with Function Templates
How can I use type traits to provide different implementations of a function template based on the properties of the template type?
Using Type Traits with Template Specialization
Can I use type traits to conditionally specialize a class template based on the properties of the template type?
Or Ask your Own Question
Get an immediate answer to your specific question using our AI assistant