if constexpr (C++17)
A compile-time branch selector that tells the compiler to include one branch and discard the other — rather than choosing at runtime. It transforms complex SFINAE and enable_if patterns into readable, single-function templates.
The problem: SFINAE boilerplate
Before C++17, writing a single function template that behaved differently depending on type properties required either multiple overloads with carefully crafted std::enable_if conditions, or tag-dispatching through helper overloads. Both approaches obscure the actual intent behind considerable machinery. Consider a function that should dereference its argument if it is a pointer, or return it directly otherwise:
// Pre-C++17: two overloads + enable_if to achieve one logical function
template <typename T,
typename = typename std::enable_if_t<std::is_pointer_v<T>, T>>
auto value_of(T value) {
return *value; // only for pointer types
}
template <typename T,
typename = typename std::enable_if_t<!std::is_pointer_v<T>, T>>
T value_of(T value) {
return value; // only for non-pointer types
}
// Two functions in the overload set; dummy template parameter required
// just to make them separate overloads; verbose and easy to get wrongWith if constexpr, both branches live inside a single function. The compiler selects which branch to compile based on the condition — the other branch is discarded and never instantiated.
// C++17: one function, intention is obvious
template <typename T>
auto value_of(T value) {
if constexpr (std::is_pointer_v<T>)
return *value; // selected and compiled when T is a pointer
else
return value; // selected and compiled when T is not a pointer
}
auto v1 = value_of(42); // v1 = 42
auto p = std::make_unique<int>(42);
auto v2 = value_of(p.get()); // v2 = 42 (dereferenced)How the compiler processes it
The condition in if constexpr must be a compile-time constant expression convertible to bool — a type trait value, a constant, a constexprvariable, or any other expression the compiler can evaluate at compile time. The general form is:
if constexpr (compile-time-condition)
// "taken" branch — compiled, included in the translation unit
else
// "discarded" branch — NOT instantiated for this template argumentThe critical rule that makes if constexpr useful in templates is: the discarded branch is not instantiated. This means the discarded branch can contain code that would be ill-formed for the current template arguments — as long as it is syntactically valid and any names used are at least declared somewhere.
This is fundamentally different from a regular if statement: in a normal if, both branches are compiled even if the condition is always true or false. The compiler just optimizes the dead code away at codegen time, but it still type-checks and instantiates both paths.
template <typename T>
void process(T value) {
// Regular if: BOTH branches compiled → fails if T has no .serialize()
if (std::is_class_v<T>)
value.serialize(); // compile error for T=int even if never reached
else
std::cout << value;
}
template <typename T>
void process_constexpr(T value) {
// if constexpr: discarded branch not instantiated → safe
if constexpr (std::is_class_v<T>)
value.serialize(); // only compiled for class types — OK for T=int
else
std::cout << value; // only compiled for non-class types
}Different return types per branch
Because discarded branches are not instantiated, they also do not participate in return type deduction. This means a single function template can legitimately return different types depending on the template argument — something that would normally require multiple overloads.
// Returns T for non-pointers, std::remove_pointer_t<T> for pointers
template <typename T>
auto unwrap(T value) {
if constexpr (std::is_pointer_v<T>)
return *value; // return type: std::remove_pointer_t<T>
else
return value; // return type: T
}
// For T=int*: return type is int
// For T=int: return type is int
// For T=float*: return type is float
// auto deduces each separately; discarded branch doesn't interfere// Practical: generic to_string that handles arithmetic, string, and "anything else"
template <typename T>
std::string to_string_generic(T val) {
if constexpr (std::is_arithmetic_v<T>)
return std::to_string(val);
else if constexpr (std::is_same_v<T, std::string>)
return val;
else if constexpr (std::is_same_v<T, std::string_view>)
return std::string{val};
else
return val.to_string(); // requires T to have .to_string()
// Only compiled for types that don't match any earlier branch
}
to_string_generic(42); // calls std::to_string
to_string_generic(std::string{"hi"}); // returns directly
to_string_generic(my_type{}); // calls .to_string()Replacing the variadic base-case overload
The other major use of if constexpr is eliminating the separate base-case overload in variadic template recursion. Previously, every recursive variadic function needed two definitions: the recursive case and a base case that accepted zero or one element. With if constexpr and sizeof...(pack), both cases fit inside a single function body.
// Before C++17: two overloads required
template <typename T>
T sum_before(T a) { return a; } // base case (leaks into outer scope)
template <typename T, typename... Args>
T sum_before(T a, Args... args) {
return a + sum_before(args...); // recursive case
}
// C++17: single function, no leaked base-case overload
template <typename T, typename... Args>
T sum(T a, Args... args) {
if constexpr (sizeof...(args) == 0)
return a; // base case: nothing left
else
return a + sum(args...); // recursive case
}
sum(1, 2, 3, 4, 5); // → 15Note: for simple accumulations, fold expressions are even cleaner. if constexpr shines when the branches do structurally different things, not just the same operation accumulated.
// Complex dispatch: different behaviour per type in a pack
template <typename T, typename... Rest>
void print_typed(T first, Rest... rest) {
if constexpr (std::is_integral_v<T>)
std::cout << "[int:" << first << "] ";
else if constexpr (std::is_floating_point_v<T>)
std::cout << std::fixed << std::setprecision(2) << "[float:" << first << "] ";
else
std::cout << "[str:" << first << "] ";
if constexpr (sizeof...(rest) > 0)
print_typed(rest...); // recurse only if more args remain
else
std::cout << '\n';
}
print_typed(42, 3.14, "hello");
// [int:42] [float:3.14] [str:hello]What the discarded branch still must satisfy
"Not instantiated" does not mean "not parsed." The compiler still parses the discarded branch for syntax errors and resolves names that do not depend on template parameters. Any name that does depend on template parameters is simply not checked. This leads to an important rule: the discarded branch must be syntactically correct and all non-dependent names must be valid at the point of definition.
// Fine: discarded branch has a dependent name (value.serialize())
// The compiler doesn't check it for T=int because serialize depends on T
template <typename T>
void save(T value) {
if constexpr (std::is_class_v<T>)
value.serialize(); // ← dependent on T, not checked when discarded
else
std::cout << value;
}
// Not fine: syntax error in discarded branch still fails to compile
template <typename T>
void broken(T value) {
if constexpr (std::is_integral_v<T>)
return value + 1;
else
return value ++; // ← 'value ++' with a space causes a syntax error
// Syntax is checked regardless of whether this branch is taken
}// Also fine: discarded branch can call non-existent member, as long as
// the expression depends on a template parameter
template <typename T>
auto optional_size(T value) {
if constexpr (requires { value.size(); }) // C++20 requires-expression
return value.size();
else
return 0uz; // returns 0 for types without .size()
}
// For C++17, use std::is_member_function_pointer or a type trait insteadif constexpr outside templates
if constexpr is not restricted to templates. It works in any function, including regular non-template ones. In a non-template context, the discarded branch is still not evaluated, but because there are no template parameters to substitute,both branches must be fully valid — dependent-name relaxation doesn't apply. The main use outside templates is to select platform-specific or debug/release paths without preprocessor macros.
constexpr bool IS_DEBUG = true; // set by build system or CMake
void initialize() {
if constexpr (IS_DEBUG) {
setup_debug_logging();
validate_invariants(); // skipped entirely in release builds
}
setup_core_systems();
}
// Compiler elides the debug block entirely in release — no runtime overhead,
// no #ifdef, no accidental double-semi-colons or other preprocessor bugsQuick reference
| Property | Regular if | if constexpr |
|---|---|---|
| Condition evaluated | At runtime | At compile time (must be constexpr) |
| Discarded branch compiled? | Yes — both branches compiled | No — discarded branch not instantiated |
| Type-dependent names in discarded branch | Must be valid | Not checked |
| Return type deduction | Both branches contribute | Only selected branch contributes |
| Works in non-templates? | Yes | Yes, but both branches still must be syntactically valid |
| Empty pack guard in variadic | Needs separate overload | sizeof...(args) == 0 inside the same function |