Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 9 – Templates and concepts

Type traits, if constexpr and reading SFINAE code

Ask questions about types with <type_traits>, choose code paths at compile time with if constexpr, and rewrite older enable_if and void_t code as concepts.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Ask questions about types with the traits of <type_traits>, after stripping references
  • Predict which branch of an if constexpr chain each instantiation keeps
  • Read older SFINAE code built on enable_if and void_t
  • Fix two enable_if overloads that redefine each other
  • Rewrite enable_if and detection code as concepts

Before you start

What you’ll learn · Before you start

What you will learn

  • Ask questions about types with the traits of <type_traits>, after stripping references
  • Predict which branch of an if constexpr chain each instantiation keeps
  • Read older SFINAE code built on enable_if and void_t
  • Fix two enable_if overloads that redefine each other
  • Rewrite enable_if and detection code as concepts

Before you start

On this page

std::is_integral_v<const int&> is false, std::is_signed_v<double> is true, and std::is_signed_v<char> is true on this Mac but false on Arm Linux. Questions about types have exact answers, not always the expected ones. A type trait: A class template of <type_traits> that the compiler answers about a type, such as std::is_integral<T>, read through its _v variable template, or that produces a new type, such as std::remove_cvref_t<T>. Every answer is known while compiling. asks them while the program compiles, and if constexpr uses the answer to keep one branch of a function template for each type, so that one template can run different code for numbers, strings and containers.

You will read the answers of four traits for eight types, write an if constexpr chain whose order matters, see why a plain if fails to compile where if constexpr succeeds, and read the SFINAE: Substitution failure is not an error: when putting deduced template arguments into a function template's declaration gives an invalid type or expression, the template quietly leaves the set of candidates. An error inside the body is still an error. code of libraries written before C++20, which uses std::enable_if and std::void_t to drop a template whose declaration does not fit a type, then rewrite it with the concepts of the previous lesson. Every example ran with Apple clang 21 and GCC 16.

A type trait answers a question about a type

The header <type_traits> declares more than a hundred traits: std::is_integral_v<T> is true when T is an integer type, and std::remove_cvref_t<T> is T without const, volatile or a reference. Each trait is a class template, and since C++17 a _v variable template gives its answer directly (paper P0006R0), while since C++14 a _t alias gives the type a transformation produces (paper N3655). Older code spells the same things out as std::is_integral<T>::value and typename std::remove_cv<T>::type. This program asks four questions of eight types:

traits.cpp C++ · traits.cpp
// The same four questions asked of eight types, all answered by the compiler.
#include <print>
#include <string>
#include <type_traits>

template <typename T>
void row(const char* name) {
    std::println("{:<12} integral={:<5} signed={:<5} floating={:<5} integral after remove_cvref={}", name,
                 std::is_integral_v<T>, std::is_signed_v<T>, std::is_floating_point_v<T>,
                 std::is_integral_v<std::remove_cvref_t<T>>);
}

int main() {
    row<int>("int");
    row<unsigned>("unsigned");
    row<const int&>("const int&");
    row<bool>("bool");
    row<char>("char");
    row<double>("double");
    row<std::string>("std::string");
    row<int*>("int*");
}
Four traits, eight types Python · traits_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra traits.cpp -o traits && ./traits
g++-16 -std=c++23 -Wall -Wextra traits.cpp -o traits-gcc && ./traits-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra traits.cpp -o traits && ./traits
int          integral=true  signed=true  floating=false integral after remove_cvref=true
unsigned     integral=true  signed=false floating=false integral after remove_cvref=true
const int&   integral=false signed=false floating=false integral after remove_cvref=true
bool         integral=true  signed=false floating=false integral after remove_cvref=true
char         integral=true  signed=true  floating=false integral after remove_cvref=true
double       integral=false signed=true  floating=true  integral after remove_cvref=false
std::string  integral=false signed=false floating=false integral after remove_cvref=false
int*         integral=false signed=false floating=false integral after remove_cvref=false
$ g++-16 -std=c++23 -Wall -Wextra traits.cpp -o traits-gcc && ./traits-gcc
int          integral=true  signed=true  floating=false integral after remove_cvref=true
unsigned     integral=true  signed=false floating=false integral after remove_cvref=true
const int&   integral=false signed=false floating=false integral after remove_cvref=true
bool         integral=true  signed=false floating=false integral after remove_cvref=true
char         integral=true  signed=true  floating=false integral after remove_cvref=true
double       integral=false signed=true  floating=true  integral after remove_cvref=false
std::string  integral=false signed=false floating=false integral after remove_cvref=false
int*         integral=false signed=false floating=false integral after remove_cvref=false

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 traits_session.py

The traits fall into a few families:

Family Examples What it gives
Primary categories is_integral, is_floating_point, is_pointer, is_enum, is_class what kind of type it is
Properties is_signed, is_const, is_trivially_copyable a further fact about it
Relations is_same, is_base_of, is_convertible how two types relate
Transformations remove_cvref_t, decay_t, make_unsigned_t, conditional_t<B, X, Y> a new type; conditional_t picks X or Y

Four rows of the program’s output hold the usual surprises:

  • const int& is not integral, because a reference type is a category of its own. Strip it first with std::remove_cvref_t, as the last column does; template parameters deduced from forwarding reference: A reference written auto&&, or T&& in a template whose T is deduced, that binds to anything: it becomes an lvalue reference for a named variable and an rvalue reference for a temporary. More in “C++ auto, decltype and type aliases” are often references, so this comes up constantly.
  • double is signed: for arithmetic types the standard defines is_signed_v<T> as T(-1) < T(0), which holds for floating-point types. Test for floating point before you test for signed.
  • bool is integral but not signed. Check for it first when it must be treated differently from numbers.
  • char is signed, on this Mac. The next section shows why that answer is not universal.

Key idea

Strip references and const with std::remove_cvref_t before you ask, and ask about bool and floating point before you ask about the sign.

How a trait is built

A trait is an ordinary class template with a value member, usually inherited from std::true_type or std::false_type, plus specialisations for the types whose answer differs, as the specialisation lesson built by hand. A few traits, such as is_union and is_trivially_copyable, cannot be written in plain C++ and use built-in compiler functions instead. Write traits of your own for questions about your own types, but never specialise the standard ones: a program that adds specialisations to the templates of <type_traits> has undefined behaviour: What a program does when it breaks a rule for which the C++ standard sets no requirements, such as reading past the end of an array. The compiler may assume it never happens, so the result can change with the compiler and the optimisation level. More in “Object lifetime and undefined behaviour in C++”, unless a trait’s description allows it.

Checkpoint

Check: Type traits and their surprises

  1. Question 1 of 2 A template parameter T was deduced as const long&. Which test is true?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: std::is_integral_v<std::remove_cvref_t<T>>

    Template parameters deduced from forwarding references are often references, and every primary category trait answers false for a reference. Strip it first with std::remove_cvref_t, then ask.

  2. Question 2 of 2 In a chain of if constexpr tests, why must the floating-point test come before any test of std::is_signed_v?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because std::is_signed_v<double> is true, so a double would take the signed-integer branch

    is_signed_v describes a property that floating-point types share with signed integers. Ask what kind of type it is first (bool, floating point), then which kind of integer.

Plain char is signed on one target and unsigned on another

The C++ standard lets each platform decide whether plain char is signed, so the answer is implementation-defined behaviour: Behaviour that the C++ standard leaves to each implementation, which must document its choice, such as sizeof(int). A program that relies on it is valid, but may give other results with another compiler or platform. More in “Object lifetime and undefined behaviour in C++”. Apple’s ARM64 platforms make it signed; the standard ABI for 64-bit Arm, which Linux follows, makes it unsigned. A trait answers for the platform the program is compiled for:

char_sign.cpp C++ · char_sign.cpp
// Is plain char signed? The answer belongs to the platform the program is compiled for.
#include <print>
#include <type_traits>

int main() {
    char c = static_cast<char>(200);
    std::println("std::is_signed_v<char> = {}, char(200) holds {}", std::is_signed_v<char>, static_cast<int>(c));
}
Plain char on two targets, and with -funsigned-char Python · char_sign_session.py
from session import run

# The compiler defines __CHAR_UNSIGNED__ on targets whose char is unsigned.
run("""
clang++ --target=aarch64-linux-gnu -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined"
clang++ --target=arm64-apple-macos -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined"
clang++ -std=c++23 char_sign.cpp -o char_sign && ./char_sign
clang++ -std=c++23 -funsigned-char char_sign.cpp -o char_sign && ./char_sign
g++-16 -std=c++23 -funsigned-char char_sign.cpp -o char_sign-gcc && ./char_sign-gcc
""")

Output

$ clang++ --target=aarch64-linux-gnu -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined"
#define __CHAR_UNSIGNED__ 1
#define __WCHAR_UNSIGNED__ 1
$ clang++ --target=arm64-apple-macos -dM -E -x c++ /dev/null | grep CHAR_UNSIGNED || echo "not defined"
not defined
$ clang++ -std=c++23 char_sign.cpp -o char_sign && ./char_sign
std::is_signed_v<char> = true, char(200) holds -56
$ clang++ -std=c++23 -funsigned-char char_sign.cpp -o char_sign && ./char_sign
std::is_signed_v<char> = false, char(200) holds 200
$ g++-16 -std=c++23 -funsigned-char char_sign.cpp -o char_sign-gcc && ./char_sign-gcc
std::is_signed_v<char> = false, char(200) holds 200

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 char_sign_session.py

Asked to compile for 64-bit Arm Linux, Clang defines the macro __CHAR_UNSIGNED__; for this Mac it does not. On the Mac std::is_signed_v<char> is true and char(200) holds -56, the same bits read as a signed value. Both compilers accept -funsigned-char, which makes plain char unsigned on any target, and the same program then prints false and 200:

Built for Plain char Shown by
This Mac (Apple ARM64) signed: char(200) holds -56 the run, and no __CHAR_UNSIGNED__
64-bit Arm Linux unsigned Clang defines __CHAR_UNSIGNED__ for that target
Any target, with -funsigned-char unsigned: char(200) holds 200 the runs with Clang and GCC 16

Code that needs a definite range should use signed char, unsigned char or std::uint8_t; a trait can then confirm it with static_assert.

Programmer Calculator (Bitwise) Enter 0xC8 and read it as an 8-bit signed and unsigned value: -56 and 200, the two answers above.

Checkpoint

Check: Plain char and the target

  1. Question 1 of 2 The program built with -funsigned-char prints char(200) holds …. What number does it print?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 200

    With -funsigned-char, plain char is unsigned, so the bits 0xC8 are read as 200. Without the flag on this Mac, char is signed and the same bits are read as -56, as the first run printed.

  2. Question 2 of 2 Which type holds the values 0 to 255 on every target?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: unsigned char (or std::uint8_t)

    The standard leaves the sign of plain char to each platform. When a range matters, write signed char, unsigned char or a fixed-width type such as std::uint8_t.

if constexpr keeps one branch for each instantiation

if constexpr is an if whose condition is a compile-time answer. to_text uses four of them to turn a value into text, with one rule for bool, one for numbers, one for strings and one for ranges, whose elements it converts recursively:

to_text.cpp C++ · to_text.cpp
// One function template with a different body for each kind of type, chosen by if constexpr.
#include <print>
#include <ranges>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>

template <typename T>
std::string to_text(const T& value) {
    if constexpr (std::is_same_v<T, bool>) {
        return value ? "yes" : "no";
    } else if constexpr (std::is_arithmetic_v<T>) {
        return std::to_string(value);
    } else if constexpr (std::is_convertible_v<const T&, std::string_view>) {
        return '"' + std::string(std::string_view(value)) + '"';
    } else if constexpr (std::ranges::range<T>) {
        std::string out = "[";
        for (const auto& element : value) {
            if (out.size() > 1) out += ", ";
            out += to_text(element);   // a call for the element type: another instantiation
        }
        return out + "]";
    } else {
        static_assert(false, "to_text: no rule for this type");
    }
}

int main() {
    std::println("{}", to_text(42));
    std::println("{}", to_text(2.5));
    std::println("{}", to_text(true));
    std::println("{}", to_text(std::string("chai")));
    std::println("{}", to_text("samosa"));
    std::println("{}", to_text(std::vector<int>{3, 1, 4}));
    std::println("{}", to_text(std::vector<std::vector<std::string>>{{"a", "b"}, {}, {"c"}}));
}
Seven calls, four rules Python · to_text_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra to_text.cpp -o to_text && ./to_text
g++-16 -std=c++23 -Wall -Wextra to_text.cpp -o to_text-gcc && ./to_text-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra to_text.cpp -o to_text && ./to_text
42
2.500000
yes
"chai"
"samosa"
[3, 1, 4]
[["a", "b"], [], ["c"]]
$ g++-16 -std=c++23 -Wall -Wextra to_text.cpp -o to_text-gcc && ./to_text-gcc
42
2.500000
yes
"chai"
"samosa"
[3, 1, 4]
[["a", "b"], [], ["c"]]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 to_text_session.py

Each call instantiates to_text for one type, and in each instantiation the conditions are constants. The compiler keeps the first branch whose condition is true and discards the rest: each discarded statement: The branch an if constexpr does not take. Inside a template it is not instantiated for that template argument, so it may hold code that would not compile for the current type; in ordinary code it is still checked. is not instantiated, so code that makes no sense for that type, such as std::to_string on a std::vector, is never compiled for it. Step through the seven calls and watch which branch each type keeps:

Interactive · stage by stage The branch each call of to_text keeps Step through main of to_text.cpp one call at a time. The tests run from the top; the first true one is kept, every branch after it is discarded for that type, and the last stage is what the program printed.
The branch each call of to_text keeps, step 7 of 7. Showing is_same_v<T, bool> (no, at every level), is_arithmetic_v<T> (no), Converts to string_view (kept for the strings), std::ranges::range<T> (kept for both vectors), static_assert(false) (discarded), What it printed ([["a", "b"], [], ["c"]]). In focus: std::ranges::range<T>, Converts to string_view, What it printed.is_same_v<T, bool>no, at every levelis_arithmetic_v<T>noConverts to string_viewkept for the stringsstd::ranges::range<T>kept for both vectorsstatic_assert(false)discardedWhat it printed[["a", "b"], [], ["c"]]

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. to_text(42). T is int. It is not bool, but it is arithmetic, so the second branch is kept and std::to_string writes 42. The string, range and static_assert branches are discarded: never compiled for int.
  2. to_text(2.5). T is double, arithmetic too, so to_text<double> keeps the same branch. std::to_string writes a double with six decimals: 2.500000.
  3. to_text(true). T is bool, and the first test catches it before the arithmetic one can, so true is written as yes. bool is an arithmetic type, so with the tests swapped std::to_string(true) would give 1.
  4. A std::string. T is std::string: not bool, not arithmetic, but convertible to std::string_view, so the third branch puts it in quotes. A string is also a range of chars; the range test is never reached for it.
  5. A string literal. The parameter is const T&, so "samosa" keeps its array type: T is char[7], six letters and the final zero. A reference to that array converts to std::string_view, so it is quoted like a string.
  6. A vector of ints. std::vector<int> fails the first three tests and is a range, so the loop runs, calling to_text for each element: to_text<int>, the instantiation of the first step, made once and used three times.
  7. Vectors of vectors. The nested vector uses three instantiations, one a level: the outer one keeps the range branch, to_text<std::vector<std::string>> keeps it too, and to_text<std::string>, made already for "chai", keeps the string branch.

The vector of vectors of strings uses three instantiations, one per level: both vector levels keep the range branch, and to_text<std::string> keeps the string branch. Three calls of the program, for int, std::string and std::vector<int>, show three instantiations that each keep a different branch:

The to_text template instantiated for int, std::string and std::vector<int>; each copy keeps one if constexpr branch.to_text<T>: four if constexpr branchesbool · arithmetic · string-like · rangethen static_assert(false)Instantiated once per typeto_text<int>kept: the arithmetic branch,std::to_string(value)discarded: string-like, range, static_assertto_text<std::string>kept: the string-like branch,the text in quotesdiscarded: range, static_assertto_text<std::vector<int>>kept: the range branch, a loopthat calls to_text<int>for each element

One template, a different branch in each instantiation

Text description of the diagram

The function template to_text<T> has four if constexpr branches, for bool, arithmetic types, string-like types and ranges, and ends with static_assert(false). The compiler instantiates it once per type it is called with.

  1. to_text<int> keeps the arithmetic branch, which calls std::to_string(value). The string-like branch, the range branch and the static_assert are discarded: they are never compiled for int.
  2. to_text<std::string> keeps the string-like branch, which puts the text in quotes. The range branch and the static_assert are discarded, even though a std::string is also a range of characters, because the string-like branch comes first.
  3. to_text<std::vector<int>> keeps the range branch, a loop that calls to_text<int> for each element, which is the first instantiation again.

The order of the branches matters. bool comes before the arithmetic test because bool is an arithmetic type, and std::to_string(true) would give 1. The string test comes before the range test because a std::string is also a range of characters.

Checkpoint

Check: One branch kept per instantiation

  1. Question 1 of 2 If the bool test of to_text came after the arithmetic test, what would to_text(true) print?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 1

    An if constexpr chain keeps the first branch whose condition is true. When one category includes another, as the arithmetic types include bool, the narrower test has to come first.

  2. Question 2 of 2 Which branch does to_text("samosa") keep, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The string branch, because T is char[7] and a reference to it converts to std::string_view

    Deduction for const T& keeps an array whole, so T is char[7]. The chain then tests that type from the top, and the first branch that fits it is the string-like one.

A plain if compiles every branch

With an ordinary if, both branches are compiled for every T, even though only one can run:

plain_if.cpp C++ · plain_if.cpp
// The same idea with a plain if: both branches are compiled for every T.
#include <string>
#include <type_traits>

template <typename T>
std::string to_text(const T& value) {
    if (std::is_arithmetic_v<T>) {
        return std::to_string(value);
    } else {
        return '"' + std::string(value) + '"';
    }
}

int main() {
    std::string a = to_text(42);
    std::string b = to_text(std::string("chai"));
}
unsupported.cpp C++ · unsupported.cpp
// to_text with a type it has no rule for: the static_assert(false) in the last branch fires.
#include <string>
#include <type_traits>

struct Point {
    int x, y;
};

template <typename T>
std::string to_text(const T& value) {
    if constexpr (std::is_arithmetic_v<T>) {
        return std::to_string(value);
    } else {
        static_assert(false, "to_text: no rule for this type");
    }
}

int main() {
    std::string a = to_text(42);
    std::string b = to_text(Point{1, 2});
}
A plain if, then a type with no rule Python · if_constexpr_errors_session.py
from session import run

# A plain if: count the lines and list the errors only
run("""
clang++ -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
clang++ -std=c++23 -c plain_if.cpp 2>&1 | grep -E "error:|requested here"
g++-16 -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
# A type with no rule, in C++23 and in C++17 mode
clang++ -std=c++23 -c unsupported.cpp
g++-16 -std=c++17 -c unsupported.cpp
""")

Output

$ clang++ -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
129 lines, 2 errors
$ clang++ -std=c++23 -c plain_if.cpp 2>&1 | grep -E "error:|requested here"
plain_if.cpp:10:22: error: no matching conversion for functional-style cast from 'const int' to 'std::string' (aka 'basic_string<char>')
plain_if.cpp:15:21: note: in instantiation of function template specialization 'to_text<int>' requested here
plain_if.cpp:8:16: error: no matching function for call to 'to_string'
plain_if.cpp:16:21: note: in instantiation of function template specialization 'to_text<std::string>' requested here
$ g++-16 -std=c++23 -c plain_if.cpp 2>&1 | awk '/error:/ { e++ } END { print NR " lines, " e (e == 1 ? " error" : " errors") }'
230 lines, 3 errors
# A type with no rule, in C++23 and in C++17 mode
$ clang++ -std=c++23 -c unsupported.cpp
unsupported.cpp:14:23: error: static assertion failed: to_text: no rule for this type
   14 |         static_assert(false, "to_text: no rule for this type");
      |                       ^~~~~
unsupported.cpp:20:21: note: in instantiation of function template specialization 'to_text<Point>' requested here
   20 |     std::string b = to_text(Point{1, 2});
      |                     ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++17 -c unsupported.cpp
unsupported.cpp: In instantiation of 'std::string to_text(const T&) [with T = Point; std::string = std::__cxx11::basic_string<char>]':
unsupported.cpp:20:28:   required from here
   20 |     std::string b = to_text(Point{1, 2});
      |                     ~~~~~~~^~~~~~~~~~~~~
unsupported.cpp:14:23: error: static assertion failed: to_text: no rule for this type
   14 |         static_assert(false, "to_text: no rule for this type");
      |                       ^~~~~
  • 'false' evaluates to false
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 if_constexpr_errors_session.py

to_text<int> fails in the string branch and to_text<std::string> in the number branch: two errors from Clang in 129 lines, three from GCC in 230. Step through what each instantiation asks of its two branches, then the safe ending:

Interactive · step by step A plain if compiles both branches for every type Step through the two instantiations of to_text in plain_if.cpp. Each column is one type, with the two branches the compiler has to compile for it.
A plain if compiles both branches for every type, step 7 of 7. Showing to_text<int>, Line 8: to_string (compiles for int), Line 10: std::string(…) (error: requested at line 15), to_text<std::string>, Line 8: to_string (error: requested at line 16), Line 10: std::string(…) (compiles for a string), to_text<Point>, static_assert(false) (fires for Point only). In focus: static_assert(false).reaches itto_text<int>from to_text(42)Line 8: to_stringcompiles for intLine 10: std::string(…)error: requested at line 15to_text<std::string>from std::string("chai")Line 8: to_stringerror: requested at line 16Line 10: std::string(…)compiles for a stringto_text<Point>unsupported.cppstatic_assert(false)fires for Point only

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The condition is a constant. to_text(42) instantiates to_text<int>, and is_arithmetic_v<int> is true while compiling. With a plain if, that changes nothing about compiling: both branches are compiled for int.
  2. The number branch for int. Line 8 calls std::to_string(value) with an int, which is fine. This branch is the one that would run.
  3. The text branch for int. Line 10 builds std::string(value) from an int, which no constructor accepts: no matching conversion for functional-style cast from 'const int'. An error in a branch that can never run.
  4. The same for std::string. to_text(std::string("chai")) instantiates to_text<std::string>. Now line 8 is the problem: std::to_string has no overload for a string, no matching function for call to 'to_string'.
  5. Two errors, neither can run. Clang reported the two errors in 129 lines, GCC 16 three errors in 230. With if constexpr, each instantiation discards the branch that does not fit, and to_text.cpp compiles.
  6. The safe ending. unsupported.cpp ends its if constexpr chain with static_assert(false, "to_text: no rule for this type"). It fires only for a type that reaches that branch: Point, with your message, at line 14.
  7. In C++17 mode too. Paper P2593R1 allowed this as a fix to earlier standards, so GCC 16 with -std=c++17 also reported it only for Point. The int call keeps the other branch and compiles.

The second file shows that safe ending for an if constexpr chain. Its last branch holds static_assert(false, "…"), which fires only when a type reaches it, here Point, with your own message.

static_assert(false) in a discarded branch

Older compilers rejected static_assert(false) in a template even in a branch that was never taken, and code used a helper such as always_false<T> instead. Paper P2593R1 changed the rule as a fix to earlier standards (core issue 2518): Clang accepts it from Clang 17, and in the run above GCC 16 with -std=c++17 reports it only for Point, the one type that reaches it.

Discarding happens only inside a template, where the condition depends on a template parameter. In an ordinary function, if constexpr (sizeof(int) == 2) never runs its branch, but the branch is still checked like any other code, so a call to a function that was never declared is an error there.

Checkpoint

Check: A plain if compiles every branch

  1. Question 1 of 2 With a plain if, why does to_text(std::string("chai")) fail to compile?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The number branch, std::to_string(value), is compiled for std::string too, and to_string has no overload for a string

    A plain if decides at run time which branch runs, so the compiler must compile both for every type. if constexpr decides while compiling and discards the branch that does not fit.

  2. Question 2 of 2 What does static_assert(false, "…") in the last branch of an if constexpr chain do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It fires only when a type reaches that branch, with your message

    The final static_assert turns a type with no rule into a clear error with your own message, instead of an error somewhere inside a branch that was never meant for it.

SFINAE quietly drops a template whose declaration fails

Before C++20 there was no way to write a constraint, so libraries used a rule of template argument deduction instead. When the compiler substitutes the deduced types into a function template’s declaration and the result is an invalid type or expression, deduction fails and the template quietly leaves the set of candidates. The standard limits this to the “immediate context”, the declaration itself: an error inside the body is still an error. That rule is SFINAE, “substitution failure is not an error”.

std::enable_if_t<Condition, T> names the type T when Condition is true and names nothing at all when it is false. Put it in a declaration and the declaration becomes invalid exactly when the condition fails:

One rule, written with enable_if and as a concept

gcd_enable_if.cpp

// C++11 to C++17: enable_if removes the template from overload resolution unless T is an integer type.
#include <type_traits>

template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
T gcd_of(T a, T b) {
    while (b != 0) {
        T r = a % b;
        a = b;
        b = r;
    }
    return a;
}

int main() {
    int g = gcd_of(84, 36);
    double h = gcd_of(8.4, 3.6);
    return g + static_cast<int>(h);
}

gcd_concept.cpp

// C++20: the same rule as a concept.
#include <concepts>

template <std::integral T>
T gcd_of(T a, T b) {
    while (b != 0) {
        T r = a % b;
        a = b;
        b = r;
    }
    return a;
}

int main() {
    int g = gcd_of(84, 36);
    double h = gcd_of(8.4, 3.6);
    return g + static_cast<int>(h);
}
gcd_of(8.4, 3.6) rejected by each version Python · gcd_session.py
from session import run

run("""
clang++ -std=c++23 -c gcd_enable_if.cpp
clang++ -std=c++23 -c gcd_concept.cpp
g++-16 -std=c++23 -c gcd_enable_if.cpp
g++-16 -std=c++23 -c gcd_concept.cpp
""")

Output

$ clang++ -std=c++23 -c gcd_enable_if.cpp
gcd_enable_if.cpp:16:16: error: no matching function for call to 'gcd_of'
   16 |     double h = gcd_of(8.4, 3.6);
      |                ^~~~~~
gcd_enable_if.cpp:5:3: note: candidate template ignored: requirement 'std::is_integral_v<double>' was not satisfied [with T = double]
    5 | T gcd_of(T a, T b) {
      |   ^
1 error generated.
[exit status 1]
$ clang++ -std=c++23 -c gcd_concept.cpp
gcd_concept.cpp:16:16: error: no matching function for call to 'gcd_of'
   16 |     double h = gcd_of(8.4, 3.6);
      |                ^~~~~~
gcd_concept.cpp:5:3: note: candidate template ignored: constraints not satisfied [with T = double]
    5 | T gcd_of(T a, T b) {
      |   ^
gcd_concept.cpp:4:11: note: because 'double' does not satisfy 'integral'
    4 | template <std::integral T>
      |           ^
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__concepts/arithmetic.h:28:20: note: because 'is_integral_v<double>' evaluated to false
   28 | concept integral = is_integral_v<_Tp>;
      |                    ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -c gcd_enable_if.cpp
gcd_enable_if.cpp: In function 'int main()':
gcd_enable_if.cpp:16:22: error: no matching function for call to 'gcd_of(double, double)'
   16 |     double h = gcd_of(8.4, 3.6);
      |                ~~~~~~^~~~~~~~~~
  • there is 1 candidate
    • candidate 1: 'template<class T, typename std::enable_if<is_integral_v<T>, int>::type <anonymous> > T gcd_of(T, T)'
      gcd_enable_if.cpp:5:3:
          5 | T gcd_of(T a, T b) {
            |   ^~~~~~
      • template argument deduction/substitution failed:
        • error: no type named 'type' in 'struct std::enable_if<false, int>'
          gcd_enable_if.cpp:4:70:
              4 | template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
                |                                                                      ^
[exit status 1]
$ g++-16 -std=c++23 -c gcd_concept.cpp
gcd_concept.cpp: In function 'int main()':
gcd_concept.cpp:16:22: error: no matching function for call to 'gcd_of(double, double)'
   16 |     double h = gcd_of(8.4, 3.6);
      |                ~~~~~~^~~~~~~~~~
  • there is 1 candidate
    • candidate 1: 'template<class T>  requires  integral<T> T gcd_of(T, T)'
      gcd_concept.cpp:5:3:
          5 | T gcd_of(T a, T b) {
            |   ^~~~~~
      • template argument deduction/substitution failed:
        • constraints not satisfied
In file included from gcd_concept.cpp:2:
          • /opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/concepts: In substitution of 'template<class T>  requires  integral<T> T gcd_of(T, T) [with T = double]':
          • required from here
            gcd_concept.cpp:16:22:   
               16 |     double h = gcd_of(8.4, 3.6);
                  |                ~~~~~~^~~~~~~~~~
          • required for the satisfaction of 'integral<T>' [with T = double]
            /opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/concepts:109:13:   
              109 |     concept integral = is_integral_v<_Tp>;
                  |             ^~~~~~~~
          • the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false'
            /opt/homebrew/Cellar/gcc/16.2.0/include/c++/16/concepts:109:24:
              109 |     concept integral = is_integral_v<_Tp>;
                  |                        ^~~~~~~~~~~~~~~~~~
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gcd_session.py

Both versions reject gcd_of(8.4, 3.6) at the call. Step through what the compiler did with the enable_if version, from the call to the candidate that disappears:

Interactive · stage by stage How enable_if removes gcd_of from the candidates for two doubles Step through the call gcd_of(8.4, 3.6) of gcd_enable_if.cpp. The template's declaration is tried with T = double; watch where it stops being valid.
How enable_if removes gcd_of from the candidates for two doubles, step 6 of 6. Showing gcd_of(8.4, 3.6), Deduce T (T = double), Substitute T (is_integral_v<double> is false), enable_if_t<false, int> (names no type at all), The template is dropped (gone from the candidates), No candidate is left (no matching function). In focus: No candidate is left, gcd_of(8.4, 3.6).gcd_of(8.4, 3.6)line 16Deduce TT = doubleSubstitute Tis_integral_v<double> is falseenable_if_t<false, int>names no type at allThe template is droppedgone from the candidatesNo candidate is leftno matching function

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A call with doubles. Line 16 calls gcd_of(8.4, 3.6). The only gcd_of is a template whose second template parameter is std::enable_if_t<std::is_integral_v<T>, int> = 0.
  2. T is double. Both arguments are doubles, so deduction gives T = double, with no conflict.
  3. The declaration with double. Before a candidate can be compared with others, the compiler puts double into its declaration. std::is_integral_v<double> is false, so the second parameter becomes std::enable_if_t<false, int>.
  4. A type that does not exist. std::enable_if<false, int> has no member named type, so std::enable_if_t<false, int> names nothing, and the declaration is invalid for double.
  5. Dropped, not rejected. An invalid declaration during substitution is not an error: the template quietly leaves the candidates. That is SFINAE. An error inside the body would still be an error, but the body is never reached.
  6. The error at the call. No candidate is left, so the call fails: no matching function for call to 'gcd_of'. Clang says the requirement 'std::is_integral_v<double>' was not satisfied; GCC 16 names the mechanism, no type named 'type' in std::enable_if<false, int>.

Clang recognises the enable_if pattern and reports requirement 'std::is_integral_v<double>' was not satisfied, which is as clear as its concept message. GCC 16 reports the mechanism instead of the reason: no type named 'type' in 'struct std::enable_if<false, int>'. For the concept, both compilers name integral and the trait inside it. The concept also reads better in the declaration and takes part in subsumption; enable_if conditions never make one overload more specialised than another.

enable_if hides in four places, and you should recognise all of them:

  • the return type, as std::enable_if_t<cond, T> f(T);
  • a default template argument, typename = std::enable_if_t<cond>;
  • a non-type template parameter, std::enable_if_t<cond, int> = 0, as in gcd_enable_if.cpp;
  • an extra function parameter with a default value.

Checkpoint

Check: SFINAE with enable_if

  1. Question 1 of 2 What does std::enable_if_t<false, int> name?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing, because std::enable_if<false, int> has no member type

    enable_if_t<cond, T> is T when cond is true and invalid when it is false. Placed in a declaration, it makes the declaration invalid exactly when the condition fails, and SFINAE then drops the template.

  2. Question 2 of 2 A template's declaration is valid for a type, but its body is not. What does SFINAE do about it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing; the error in the body is a real error

    Substitution failure removes a candidate only in the declaration. That is why enable_if has to sit in the return type, a template parameter or a function parameter, never in the body.

Two templates that differ only in a default argument clash

The default-argument form has a trap. Default template arguments are not part of a template’s signature, so two templates that differ only in them are the same template declared twice:

Two overloads that clash, and the usual fix

redecl.cpp

// Two "overloads" that differ only in a default template argument declare the same template twice.
#include <string>
#include <type_traits>

template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
std::string kind(T) { return "integer"; }

template <typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
std::string kind(T) { return "floating point"; }

int main() {
    return static_cast<int>(kind(1).size() + kind(1.0).size());
}

redecl_fixed.cpp

// The C++11 to C++17 fix: put enable_if in the type of a non-type parameter, which is part of the signature.
#include <print>
#include <string>
#include <type_traits>

template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
std::string kind(T) { return "integer"; }

template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
std::string kind(T) { return "floating point"; }

int main() {
    std::println("{} and {}", kind(1), kind(1.0));
}
A redefinition, then two working overloads Python · redecl_session.py
from session import run

run("""
clang++ -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
g++-16 -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
clang++ -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed && ./redecl_fixed
g++-16 -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed-gcc && ./redecl_fixed-gcc
""")

Output

$ clang++ -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
redecl.cpp:8:34: error: template parameter redefines default argument
redecl.cpp:9:13: error: redefinition of 'kind'
redecl.cpp:12:29: error: no matching function for call to 'kind'
redecl.cpp:12:46: error: no matching function for call to 'kind'
$ g++-16 -std=c++23 -c redecl.cpp 2>&1 | grep -E "error:"
redecl.cpp:9:13: error: redefinition of 'template<class T, class> std::string kind(T)'
redecl.cpp:12:50: error: no matching function for call to 'kind(double)'
        • error: no type named 'type' in 'struct std::enable_if<false, void>'
$ clang++ -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed && ./redecl_fixed
integer and floating point
$ g++-16 -std=c++23 -Wall -Wextra redecl_fixed.cpp -o redecl_fixed-gcc && ./redecl_fixed-gcc
integer and floating point

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 redecl_session.py

Both compilers report a redefinition of kind, and Clang adds template parameter redefines default argument: it reads the second declaration as the first template again, with a second default for a parameter that already has one. The two conditions are never compared, because by then there is only one template. Step through what the compiler compares, before and after the fix:

Interactive · step by step Why two enable_if overloads clash, and how the fix keeps them apart Step through what the compiler compares when it reads the second kind. The left column is redecl.cpp, the right one redecl_fixed.cpp.
Why two enable_if overloads clash, and how the fix keeps them apart, step 6 of 6. Showing kind #1 (default: the integral test), kind #2 (default: the floating test), What the compiler sees (one template, twice: an error), kind #1, fixed, kind #2, fixed, Two templates (integer and floating point). In focus: Two templates.kind #1default: the integral testkind #2default: the floating testWhat the compiler seesone template, twice: an errorkind #1, fixedenable_if_t<integral, int>kind #2, fixedenable_if_t<floating, int>Two templatesinteger and floating point

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first kind. redecl.cpp declares template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>: a second, unnamed type parameter whose default holds the condition.
  2. The second kind. The second kind has the same shape, with std::is_floating_point_v<T> in the default. To a reader the two say different things.
  3. Defaults are not compared. Default template arguments are not part of a template's signature. Both read template <typename T, typename> std::string kind(T): the same template, defined twice. Both compilers report a redefinition of kind.
  4. The condition moves into a type. redecl_fixed.cpp writes the condition as the type of a non-type parameter: std::enable_if_t<std::is_integral_v<T>, int> = 0. The type of a template parameter is part of the signature.
  5. A different type each. The second kind uses std::enable_if_t<std::is_floating_point_v<T>, int>, a different type, so the two are different templates, and both may exist.
  6. Each call keeps one. For kind(1), substituting int into the floating version gives std::enable_if_t<false, int>, so SFINAE drops it; kind(1.0) drops the other. The program printed integer and floating point on both compilers.

The pre-C++20 fix moves the condition into the type of a non-type parameter, std::enable_if_t<cond, int> = 0, which is part of the signature, so the two templates differ. In C++20, kind(std::integral auto) and kind(std::floating_point auto) say it without either trick.

Checkpoint

Check: Overloads that redefine each other

  1. Question 1 of 2 Which difference makes two kind(T) function templates two different templates?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Their non-type template parameters have different types, such as enable_if_t of two different conditions

    Two declarations with the same name, parameter types and template parameter list declare one template. Moving the condition into the type of a parameter makes the lists differ.

  2. Question 2 of 2 How does C++20 write the two kind overloads without enable_if?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: std::string kind(std::integral auto) and std::string kind(std::floating_point auto)

    A constrained declaration states its condition where the compiler and the reader see it, and two different constraints make two different templates.

The detection idiom asks whether an expression compiles

The other common idiom answers “does this type have a size() member?”. std::void_t<…> is void for any list of valid types (paper N3911), so a partial specialisation: A definition of a class or variable template for a family of argument lists, written as a pattern that keeps parameters to deduce, such as template <typename T> struct TypeName<T*>, which matches every pointer type. More in “C++ template specialisation and overloading” that names an expression inside it matches only when the expression is valid. This is the detection idiom: The C++17 way to ask whether an expression compiles for a type: a primary template that answers false, and a partial specialisation with std::void_t<decltype(expression)> that matches, and answers true, only when the expression is valid.:

detect.cpp C++ · detect.cpp
// Does T have a size() member? Once with the C++17 detection idiom, once with a C++20 concept.
#include <list>
#include <print>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

// The partial specialisation is chosen only when the expression inside void_t is valid for T.
template <typename T, typename = void>
struct has_size : std::false_type {};

template <typename T>
struct has_size<T, std::void_t<decltype(std::declval<const T&>().size())>> : std::true_type {};

// The same question as a concept.
template <typename T>
concept HasSize = requires(const T& t) { t.size(); };

struct Bag {
    int size() const { return 3; }
};
struct Box {};

template <typename T>
void row(const char* name) {
    std::println("{:<18} has_size={:<5} HasSize={}", name, has_size<T>::value, HasSize<T>);
}

int main() {
    row<std::vector<int>>("std::vector<int>");
    row<std::list<int>>("std::list<int>");
    row<std::string>("std::string");
    row<int[4]>("int[4]");
    row<Bag>("Bag");
    row<Box>("Box");
}
The detection idiom and a concept agree Python · detect_session.py
from session import run

run("""
clang++ -std=c++23 -Wall -Wextra detect.cpp -o detect && ./detect
g++-16 -std=c++23 -Wall -Wextra detect.cpp -o detect-gcc && ./detect-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra detect.cpp -o detect && ./detect
std::vector<int>   has_size=true  HasSize=true
std::list<int>     has_size=true  HasSize=true
std::string        has_size=true  HasSize=true
int[4]             has_size=false HasSize=false
Bag                has_size=true  HasSize=true
Box                has_size=false HasSize=false
$ g++-16 -std=c++23 -Wall -Wextra detect.cpp -o detect-gcc && ./detect-gcc
std::vector<int>   has_size=true  HasSize=true
std::list<int>     has_size=true  HasSize=true
std::string        has_size=true  HasSize=true
int[4]             has_size=false HasSize=false
Bag                has_size=true  HasSize=true
Box                has_size=false HasSize=false

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 detect_session.py

has_size<T> starts from the primary template: The general definition of a template, such as template <typename T> struct TypeName, that its specialisations refine. It is used for every argument list that no full or partial specialisation matches. More in “C++ template specialisation and overloading”, std::false_type, whose second parameter defaults to void. For has_size<Bag> the compiler also tries the specialisation; substituting Bag into decltype(std::declval<const Bag&>().size()) succeeds, void_t of it is void, the same as the default, so the specialisation matches and the answer is true. Step through Bag, Box and int[4], then the three standard containers:

Interactive · stage by stage How has_size<T> finds out whether T has a size() member Step through has_size for one type at a time. The partial specialisation is tried first; if substituting T into it fails, the primary template answers.
How has_size<T> finds out whether T has a size() member, step 6 of 6. Showing has_size<T> (has_size<std::list<int>, void>), Try the specialisation (matches: void and void), Is .size() valid? (list: valid), The answer (true for all three). In focus: Try the specialisation, The answer.has_size<T>has_size<std::list<int>, void>Try the specialisationmatches: void and voidIs .size() valid?list: validThe answertrue for all three

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The default is void. has_size<Bag> names only one argument, so the second takes its default: has_size<Bag, void>. The compiler now looks for a partial specialisation that matches those two arguments.
  2. Bag has size(). The specialisation matches when void_t<decltype(…size())> is void. For Bag, std::declval<const Bag&>().size() is a valid int expression, so void_t of it is void: a match.
  3. true for Bag. The specialisation derives from std::true_type, so has_size<Bag>::value is true, as the program printed.
  4. Box has no size(). For Box the expression is invalid. That failure happens while substituting into the specialisation, so it is not an error: SFINAE discards the specialisation, and the primary template, std::false_type, answers false.
  5. An array of ints. int[4] has no member functions at all, so .size() is invalid for it too, and has_size<int[4]> is false. std::size(array) would work, but that is a different expression.
  6. The standard containers. std::vector<int>, std::list<int> and std::string all have size(), so the specialisation matches and all three print has_size=true. The concept HasSize gave the same six answers in one line.

For Box the substitution fails, SFINAE discards the specialisation and the primary template answers false. std::declval<const T&>() produces an expression of type const T& without creating an object, which is allowed only in unevaluated places such as decltype. The concept HasSize asks the same question in one line, and the two agree for all six types.

Checkpoint

Check: Detecting members with void_t

  1. Question 1 of 2 What does std::declval<const T&>() give inside decltype?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An expression of type const T&, without creating any object

    std::declval<U>() is declared but never defined, so it is allowed only in unevaluated contexts such as decltype, which is exactly where the detection idiom needs a hypothetical object.

  2. Question 2 of 2 Why is has_size<int[4]> false, although an array has a size?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An array has no member function size(), so substitution fails and the primary template answers

    The idiom detects a particular expression, here a member call .size(). std::size(array) is a different expression, which the trait does not ask about.

Every SFINAE idiom has a concept that says it directly

Old code mixes the spellings of several standards, and each one dates the code that uses it: ::type and ::value need only C++11, a _t alias needs C++14, and a _v variable or std::void_t needs C++17. Knowing which standard an idiom belongs to tells you what a library still supports, and which concept can replace it. Walk through where each idiom of this lesson comes from:

Timeline · 5 events in order Which standard each idiom of this lesson comes from Walk through the standards in order. Old code mixes these spellings, and each event says what it added and how this lesson's examples use it.
  1. C++11

    enable_if in the standard library

    Paper N2240 proposed std::enable_if and std::conditional for the library of the standard that became C++11. enable_if<cond, T> has a member type only when cond is true, which is what SFINAE in a declaration needs. The spelling of the time is typename std::enable_if<std::is_integral<T>::value, int>::type.

  2. C++14

    _t aliases for the transformations

    Paper N3655 added alias templates such as std::remove_cv_t and std::enable_if_t, so typename std::remove_cv<T>::type became std::remove_cv_t<T>, the form gcd_enable_if.cpp and redecl.cpp use.

  3. C++17

    _v answers, void_t and if constexpr new syntax

    Paper P0006R0 added variable templates such as std::is_integral_v<T>; N3911 added std::void_t, the base of has_size in detect.cpp; and P0292R2 gave the language if constexpr, which to_text.cpp uses.

  4. C++20

    Concepts say it directly new syntax

    Paper P0734R0 brought concepts, requires-clauses and requires-expressions into the language and P0898R3 the standard concepts, so gcd_concept.cpp writes template <std::integral T> where gcd_enable_if.cpp needed enable_if.

  5. Defect report

    static_assert(false) in a template

    Paper P2593R1, a fix to earlier standards (core issue 2518), lets static_assert(false) wait until a branch is instantiated. Clang accepts it from Clang 17, and GCC 16 in -std=c++17 mode reported it only for Point. Older code used a helper such as always_false<T> instead.

The timeline has 5 events, from C++11 to Defect report.

You read You can write
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0> template <std::integral T>
std::enable_if_t<cond, R> as a return type R with a requires-clause
has_size<T>::value, built with std::void_t requires (const T& t) { t.size(); }
tag dispatch on std::true_type and std::false_type if constexpr, or overloads on concepts
static_assert(always_false<T>) static_assert(false)

Rewrite one overload set at a time and keep its behaviour. A condition that removed a template in SFINAE removes it as a constraint too, but two things change: overlapping concepts now order the overloads by subsumption, where enable_if needed mutually exclusive conditions, and error messages name the concept. A library that must still build as C++17 keeps its enable_if; it can test __cpp_concepts and offer both.

Checkpoint

Check: Rewriting SFINAE as concepts

  1. Question 1 of 2 What is the C++20 form of template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: template <std::integral T>

    A condition that removed a template through enable_if becomes a constraint, here the concept in place of typename. The meaning stays the same; the declaration and the error become readable.

  2. Question 2 of 2 Code writes typename std::enable_if<std::is_integral<T>::value, int>::type = 0. What is the oldest standard it compiles with?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: C++11

    Each spelling dates the code: ::type and ::value are C++11, _t aliases C++14, _v variables and void_t C++17, and a concept in place of all of it C++20.

Practise: a describe function for any type

describe.h declares describe<T>(), which should return a short description of T such as "unsigned integer, 4 bytes". Its comment lists seven cases in the order to test them, and bytes(n) writes “1 byte” or “4 bytes” for you:

The starter header and its sample tests

C header · describe.h

// Starter code for "Your turn": write describe<T>().
#pragma once
#include <cstddef>
#include <string>
#include <type_traits>

// "1 byte" or "4 bytes": the size part of every description.
inline std::string bytes(std::size_t n) {
    return std::to_string(n) + (n == 1 ? " byte" : " bytes");
}

// A short description of T, such as "unsigned integer, 4 bytes". The cases, in the order to test them:
//   bool                                    "bool, 1 byte"
//   signed or unsigned integer types        "signed integer, 4 bytes", "unsigned integer, 2 bytes"
//   floating-point types                    "floating point, 8 bytes"
//   pointers                                "pointer, 8 bytes"
//   enumerations                            "enum, 1 byte"
//   classes                                 "class, 24 bytes"
//   anything else                           "other, 16 bytes"
// References and const describe the type they refer to: describe<const int&>() is "signed integer, 4 bytes".
template <typename T>
std::string describe() {
    // TODO: one if constexpr branch per case, on std::remove_cvref_t<T>.
    return "other, " + bytes(sizeof(T));
}

C++ · describe_tests.cpp

// Sample tests for describe.h. Build: clang++ -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests
#include <cstdint>
#include <print>
#include <string>
#include "describe.h"

enum class Colour : std::uint8_t { red, green, blue };
struct Pixel {
    std::uint8_t r, g, b;
};

int checks = 0;
int failures = 0;

void check(const std::string& got, const std::string& want) {
    ++checks;
    if (got != want) {
        ++failures;
        std::println("FAIL got \"{}\", want \"{}\"", got, want);
    }
}

int main() {
    check(describe<int>(), "signed integer, 4 bytes");
    check(describe<unsigned short>(), "unsigned integer, 2 bytes");
    check(describe<signed char>(), "signed integer, 1 byte");
    check(describe<bool>(), "bool, 1 byte");
    check(describe<double>(), "floating point, 8 bytes");
    check(describe<float>(), "floating point, 4 bytes");
    check(describe<const long long&>(), "signed integer, 8 bytes");
    check(describe<int*>(), "pointer, " + bytes(sizeof(int*)));
    check(describe<Colour>(), "enum, 1 byte");
    check(describe<Pixel>(), "class, 3 bytes");
    check(describe<std::string>(), "class, " + bytes(sizeof(std::string)));
    std::println("{} of {} checks passed", checks - failures, checks);
    return failures == 0 ? 0 : 1;
}
The sample tests against the starter code Python · describe_session.py
from session import run

# The sample tests, built against the starter code before the TODO is done.
run("""
clang++ -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests && ./describe_tests
g++-16 -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests-gcc && ./describe_tests-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests && ./describe_tests
FAIL got "other, 4 bytes", want "signed integer, 4 bytes"
FAIL got "other, 2 bytes", want "unsigned integer, 2 bytes"
FAIL got "other, 1 byte", want "signed integer, 1 byte"
FAIL got "other, 1 byte", want "bool, 1 byte"
FAIL got "other, 8 bytes", want "floating point, 8 bytes"
FAIL got "other, 4 bytes", want "floating point, 4 bytes"
FAIL got "other, 8 bytes", want "signed integer, 8 bytes"
FAIL got "other, 8 bytes", want "pointer, 8 bytes"
FAIL got "other, 1 byte", want "enum, 1 byte"
FAIL got "other, 3 bytes", want "class, 3 bytes"
FAIL got "other, 24 bytes", want "class, 24 bytes"
0 of 11 checks passed
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra describe_tests.cpp -o describe_tests-gcc && ./describe_tests-gcc
FAIL got "other, 4 bytes", want "signed integer, 4 bytes"
FAIL got "other, 2 bytes", want "unsigned integer, 2 bytes"
FAIL got "other, 1 byte", want "signed integer, 1 byte"
FAIL got "other, 1 byte", want "bool, 1 byte"
FAIL got "other, 8 bytes", want "floating point, 8 bytes"
FAIL got "other, 4 bytes", want "floating point, 4 bytes"
FAIL got "other, 8 bytes", want "signed integer, 8 bytes"
FAIL got "other, 8 bytes", want "pointer, 8 bytes"
FAIL got "other, 1 byte", want "enum, 1 byte"
FAIL got "other, 3 bytes", want "class, 3 bytes"
FAIL got "other, 32 bytes", want "class, 32 bytes"
0 of 11 checks passed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 describe_session.py

Every check fails at first, because the starter answers “other” for everything. Write one if constexpr branch per case, on std::remove_cvref_t<T>, so that const long long& describes a long long. Mind the order the traits table taught: bool before the integers, and floating point before any test of is_signed_v. The last check compares std::string with its own sizeof, which differs between the two standard libraries: 24 bytes in libc++ and 32 in libstdc++, as the starter’s output shows. When all is done, both compilers print 11 of 11 checks passed. Then ask for describe<char>() and predict what it prints with -funsigned-char.

Then test what you can predict without a compiler: a trait’s answer, the branch a type keeps, the template SFINAE drops and the C++20 spelling of an old idiom. Practice opens with the concept you have answered least well, and each choice carries the reason it fits or fails here:

Practice that adapts to you

Practise this lesson

13 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 What is std::is_integral_v<const int&>?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: false, because a reference type is not an integral type

    References are a type category of their own, so every primary category trait answers false for them. Strip the reference and const first: std::is_integral_v<std::remove_cvref_t<const int&>> is true.

  2. Question 2 of 13 Which of these are true for double?

    Choose every answer that is right.

    Show the answer: Question 2 of 13

    Answer:

    • std::is_arithmetic_v<double>
    • std::is_signed_v<double>
    • std::is_floating_point_v<double>

    For arithmetic types, is_signed_v<T> is defined as T(-1) < T(0), which holds for double. That is why a chain of if constexpr tests should ask about floating point before it asks about signedness.

  3. Question 3 of 13 std::is_signed_v<char> printed true on the Mac. What does the same code give when compiled for 64-bit Arm Linux?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: false, because that platform makes plain char unsigned

    The standard leaves the signedness of plain char to each platform. Apple's ARM64 platforms make it signed, and the standard Arm ABI that Linux follows makes it unsigned; -funsigned-char changes it on any target. Use signed char, unsigned char or std::uint8_t when the range matters.

  4. Question 4 of 13 A build script must learn whether plain char is unsigned on a target without running any program there. What can it ask?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: The compiler's predefined macros for that target, where __CHAR_UNSIGNED__ is defined when char is unsigned

    A trait, or a predefined macro, answers for the target the code is compiled for. That makes the answer available to a cross-compiling build, and a static_assert can stop a build for a target the code was not written for.

  5. Question 5 of 13 Inside a function template, what happens to the branch an if constexpr does not take for the current T?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: It is discarded and never instantiated for that T, so it may hold code that would not compile for it

    The conditions of an if constexpr are constants in each instantiation, and the branch not taken is discarded there. Another instantiation, for another T, may keep that same branch, as to_text<std::string> and to_text<std::vector<int>> do.

  6. Question 6 of 13 What does to_text(std::vector<char>{'h', 'i'}) print?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: [104, 105]

    The chain classifies each type separately. char is an integral type, so for to_text it is a number, the same surprise as bool before the bool test was added.

  7. Question 7 of 13 A template returns if (std::is_pointer_v<T>) return *value; else return value; and is called with an int. What happens?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: It does not compile, because *value is compiled for int as well, and an int cannot be dereferenced

    Change the if to if constexpr and the pointer branch is discarded for int, so the template compiles for both pointers and values.

  8. Question 8 of 13 Where can an enable_if condition sit so that SFINAE removes a function template?

    Choose every answer that is right.

    Show the answer: Question 8 of 13

    Answer:

    • In a default template argument
    • In the type of a non-type template parameter
    • In the return type

    SFINAE acts on the declaration: the return type, the template parameters and the function parameters. The fourth place is an extra function parameter with a default value.

  9. Question 9 of 13 GCC 16 rejected gcd_of(8.4, 3.6) with no type named 'type' in 'struct std::enable_if<false, int>'. What does that tell you?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: That substitution made enable_if's condition false, so the only template left the candidates

    Clang translates the same failure into the condition, "requirement 'std::is_integral_v<double>' was not satisfied". With a concept, both compilers name integral, which is one reason to rewrite such code.

  10. Question 10 of 13 Why does this file fail to compile?

    Read the code, then choose one answer.

    template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
    std::string kind(T) { return "integer"; }
    
    template <typename T, typename = std::enable_if_t<std::is_floating_point_v<T>>>
    std::string kind(T) { return "floating point"; }
    Show the answer: Question 10 of 13

    Answer: Default template arguments are not part of the signature, so both declare the same template, which is defined twice

    The two templates differ only in their default template arguments, which do not make them different templates, so the second definition is a redefinition. Moving the condition into the type of a non-type parameter, std::enable_if_t<cond, int> = 0, makes it part of the signature; in C++20, use concepts.

  11. Question 11 of 13 In the detection idiom, why does has_size<Box> give false instead of an error, when Box has no size()?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Substituting Box into the specialisation's void_t argument fails, so SFINAE discards the specialisation and the primary template is used

    The partial specialisation matches only when decltype(std::declval<const Box&>().size()) is a valid type. For Box the substitution fails in the immediate context, which is not an error but removes the specialisation, so the primary template, std::false_type, answers.

  12. Question 12 of 13 Why does the primary template of has_size give its second parameter the default void?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: So that has_size<T> means has_size<T, void>, which the specialisation matches exactly when void_t<…> is valid, since it is then void

    The idiom lines two things up: the default argument void and the void that std::void_t produces for valid types. With any other default, the specialisation would never match.

  13. Question 13 of 13 What does the rewriting table suggest instead of tag dispatch on std::true_type and std::false_type?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: if constexpr, or overloads constrained by concepts

    Tag dispatch passed std::true_type or std::false_type to pick an overload. if constexpr does the same in one function, and concepts do it with overloads whose conditions are visible in their declarations.

Interview questions

Fresher · What does if constexpr do that a plain if does not?

Its condition must be a constant expression, and in a template the branch that is not taken is discarded: it is not instantiated for that type, so it may contain code that would not compile for it. With a plain if, both branches are compiled for every type, even if only one can run, so if (std::is_arithmetic_v<T>) return std::to_string(value); fails for a std::string argument. if constexpr lets one function template hold a separate body for numbers, strings and containers. Two details make a good answer complete. Discarding happens only inside a template, where the condition depends on a template parameter; in ordinary code the skipped branch is still fully checked. And a final else with static_assert(false, "…") turns an unsupported type into a clear error, which current compilers accept even in C++17 mode.

The usual follow-ups probe where if constexpr can choose and where it cannot. Decide these two:

Checkpoint

Interview round: two follow-up questions

  1. Question 1 of 2 What happens with template <typename T> int sign_of(T x) { if constexpr (x > 0) return 1; else return 0; } and a call sign_of(5)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It does not compile, because the condition of if constexpr must be a constant expression and x is not one

    if constexpr chooses with information the compiler has: types, traits, sizes and constants. A value known only at run time needs a plain if.

  2. Question 2 of 2 In plain main, not a template, if constexpr (sizeof(int) == 2) { undeclared_call(); } is written. Does the file compile?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No, because outside a template the discarded branch is still checked, and undeclared_call was never declared

    if constexpr can choose code paths that would not compile only inside a template, where the condition depends on a template parameter. In ordinary functions it is a compile-time if whose branches must all be valid code.

Two harder questions take traits into code you maintain: two enable_if overloads that refuse to build, and a type dispatch that writes the wrong values in production:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A C++17 library defines two serialize(T) overloads: one with typename = std::enable_if_t<std::is_integral_v<T>>, one with the same form for floating-point types. The build fails with "redefinition of serialize". Explain the cause, give the C++17 fix, and say what C++20 changes.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A to_json(value) template tests std::is_integral_v<T>, then std::is_floating_point_v<T>, then strings, with if constexpr. In production, true is written as 1, char fields appear as numbers, and a const int& member reaches the static_assert(false) at the end. Explain each bug and fix the chain.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • <type_traits> answers questions about types at compile time, in categories, properties, relations and transformations read through _v and _t, and references are not integral while double is signed.
  • Plain char is signed on this Mac and unsigned on Arm Linux, so code that needs a range names signed char, unsigned char or std::uint8_t.
  • if constexpr keeps the first true branch of each instantiation and discards the rest, so the order of its tests decides what bool and strings become.
  • A plain if compiles every branch for every type, and a final static_assert(false, "…") is the safe ending of an if constexpr chain.
  • SFINAE drops a template when substitution makes its declaration invalid, as enable_if does for gcd_of(8.4, 3.6), and templates that differ only in default template arguments clash.
  • The detection idiom with std::void_t asks whether an expression compiles, and concepts say every one of these idioms more readably, with clearer errors and ordered overloads.

Terms in this lesson

detection idiom
The C++17 way to ask whether an expression compiles for a type: a primary template that answers false, and a partial specialisation with std::void_t<decltype(expression)> that matches, and answers true, only when the expression is valid.
discarded statement
The branch an if constexpr does not take. Inside a template it is not instantiated for that template argument, so it may hold code that would not compile for the current type; in ordinary code it is still checked.
SFINAE
Substitution failure is not an error: when putting deduced template arguments into a function template's declaration gives an invalid type or expression, the template quietly leaves the set of candidates. An error inside the body is still an error.
type trait
A class template of <type_traits> that the compiler answers about a type, such as std::is_integral<T>, read through its _v variable template, or that produces a new type, such as std::remove_cvref_t<T>. Every answer is known while compiling.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.