Compile-Time Branching — Advanced
Advanced if constexpr patterns with concepts, consteval guards, SFINAE migration, and if consteval — plus the pitfalls that trip up experienced engineers.
if constexprsince C++17A compile-time branch selector that prevents instantiation of the discarded branch in a template — enabling type-specific logic without SFINAE or tag dispatch.
Overview
if constexpr evaluates its condition as a constant expression and, inside a template, removes the non-taken branch from instantiation entirely. The discarded branch never becomes machine code for those template arguments, so it can reference member functions or types that don't exist — as long as those references are dependent on a template parameter.
Without if constexpr, the equivalent required either SFINAE (std::enable_if specializations), tag dispatch, or explicit template specializations — patterns that scatter a single logical operation across multiple overloads and produce cryptic error messages.
The dependent name requirement is the most important subtlety. if constexpr suppresses instantiation only of dependent expressions. Non-dependent ill-formed code in the discarded branch is still a hard error:
template<typename T>
void example(T val) {
if constexpr (std::is_integral_v<T>) { // C++17
val.non_existent(); // discarded for non-integral T — OK: name depends on T
}
}
void non_template() {
if constexpr (false) {
int x = "not an int"; // ERROR: not in a template — both branches fully checked
}
}Outside a template, if constexpr is not #if. Both branches must be semantically valid; only the runtime execution of the non-taken branch is suppressed.
Syntax
// Basic form — C++17
if constexpr (constant-expression) statement
if constexpr (constant-expression) statement else statement
// With init-statement — C++17
if constexpr (auto x = compute(); predicate(x)) { use(x); }
// if consteval — C++23 (replaces is_constant_evaluated() idiom)
if consteval { /* compile-time path */ } else { /* runtime path */ }
if !consteval { /* runtime-only */ }The condition must be a contextually converted constant expression of type bool. It may reference constexpr variables, type traits, concept checks, sizeof, requires expressions, or any combination thereof.
Examples
Replacing SFINAE
The canonical motivation. SFINAE requires mirrored enable conditions across multiple declarations:
// C++14: SFINAE — hard to read, easy to get the negated condition wrong
template<typename Num, typename Den,
std::enable_if_t<std::is_integral_v<Num> &&
std::is_integral_v<Den>>* = nullptr>
auto divide(Num n, Den d) {
if (d == Den{0}) throw std::domain_error{"division by zero"};
return n / d;
}
template<typename Num, typename Den,
std::enable_if_t<!std::is_integral_v<Num> ||
!std::is_integral_v<Den>>* = nullptr>
auto divide(Num n, Den d) { return n / d; }
// C++17: single function, linear reading order
template<typename Num, typename Den>
auto divide(Num n, Den d) {
if constexpr (std::is_integral_v<Num> && std::is_integral_v<Den>) {
if (d == Den{0}) throw std::domain_error{"division by zero"};
}
return n / d;
}Variadic Recursion Without a Base-Case Overload
Before C++17, compile-time variadic recursion required a separate overload to terminate:
// C++17: self-contained, no second overload needed
template<typename T, typename... Rest>
T sum(T first, Rest... rest) {
if constexpr (sizeof...(rest) == 0)
return first;
else
return first + sum(rest...);
}
auto total = sum(1, 2, 3, 4); // 10Member Detection with Concepts (C++20)
// C++20
template<typename T>
concept HasSerialize = requires(const T& t) {
{ t.serialize() } -> std::convertible_to<std::string>;
};
template<typename T>
void persist(const T& val) {
if constexpr (HasSerialize<T>)
store(val.serialize());
else if constexpr (std::is_trivially_copyable_v<T>)
store_raw(&val, sizeof(val));
else
static_assert(sizeof(T) == 0, // dependent false — see Pitfalls
"T must be serializable or trivially copyable");
}Fold Expressions Combined with if constexpr
Type-specific formatting across a variadic pack using a lambda per element:
// C++17 fold + if constexpr; std::print requires C++23
template<typename... Ts>
void print_typed(const Ts&... vals) {
([](const auto& v) {
using V = std::decay_t<decltype(v)>;
if constexpr (std::is_floating_point_v<V>)
std::print("{:.4f} ", v); // C++23
else if constexpr (std::is_same_v<V, bool>)
std::print("{} ", v ? "true" : "false");
else
std::print("{} ", v); // C++23
}(vals), ...);
std::println(""); // C++23
}
print_typed(42, 3.14159, true, "hello");
// 42 3.1416 true helloconsteval and if consteval (C++20/23)
consteval (C++20) forces compile-time-only execution. if consteval (C++23) cleanly splits compile-time vs runtime paths inside a constexpr function — replacing the fragile is_constant_evaluated() idiom:
// C++20: is_constant_evaluated() in a plain if — works correctly
constexpr double accurate_sqrt(double x) {
if (std::is_constant_evaluated()) { // plain if, NOT if constexpr — see Pitfalls
double y = x;
for (int i = 0; i < 20; ++i) y = 0.5 * (y + x / y);
return y;
} else {
return std::sqrt(x); // fast runtime path
}
}
// C++23: if consteval — same semantics, no trap
constexpr double accurate_sqrt(double x) {
if consteval {
double y = x;
for (int i = 0; i < 20; ++i) y = 0.5 * (y + x / y);
return y;
} else {
return std::sqrt(x);
}
}Best Practices
Prefer concept overloads for primary dispatch. When branches apply to fundamentally different types, separate constrained overloads (C++20) compose better and produce cleaner errors than a single function with if constexpr chains:
// C++20: independently callable, documentable, mockable
void process(std::ranges::range auto const& r) { /* range path */ }
void process(std::integral auto v) { /* integer path */ }
// Reserve if constexpr for within-template logic that shares significant
// setup or teardown code across type variants.Terminate chains with a dependent static_assert. The static_assert(sizeof(T) == 0, ...) pattern gives actionable error messages when no branch matches:
template<typename T>
void handle(const T& v) {
if constexpr (std::is_integral_v<T>) { handle_int(v); }
else if constexpr (std::is_class_v<T>) { handle_obj(v); }
else {
// sizeof(T) == 0 is always false but depends on T → fires only at instantiation
static_assert(sizeof(T) == 0, "handle(): requires integral or class type");
}
}Use if consteval in C++23, not if constexpr (is_constant_evaluated()). The older form requires extra care to avoid the always-true trap (see Pitfalls).
Common Pitfalls
if constexpr outside templates does not suppress semantic checking. Both branches must be well-formed regardless of the condition value. This surprises engineers who expect #if-like behaviour:
void f() {
if constexpr (false) {
int x = "oops"; // still a compile error — not in a template
}
}if constexpr (std::is_constant_evaluated()) is always true. The condition of if constexpr is itself a constant expression context, so is_constant_evaluated() unconditionally returns true. The else branch becomes permanently dead code:
constexpr int bad(int x) {
if constexpr (std::is_constant_evaluated()) { // always true here!
return x * 2; // only branch ever taken
} else {
return x * 3; // unreachable
}
}
// Fix: use plain `if (std::is_constant_evaluated())` or C++23 `if consteval`Non-dependent static_assert fires at template definition time. A static_assert(false, ...) with no dependency on a template parameter triggers when the template is parsed, not when it's instantiated — even inside a discarded branch:
template<typename T>
void g() {
if constexpr (std::is_integral_v<T>) { }
else {
static_assert(false, "bad"); // ERROR at definition, not at use
static_assert(sizeof(T) == 0, // OK: dependent on T
"requires integral type");
}
}Mismatched return types without auto. if constexpr does not exempt a function from having consistent return type. Use auto so deduction happens per instantiation:
// Fails to compile if branches return different types
template<typename T>
auto get_value(T val) {
if constexpr (std::is_pointer_v<T>)
return *val; // deduced as pointed-to type
else
return val; // deduced as T — auto resolves per-instantiation
}Constexpr-if in variadic template recursion
if constexpr eliminates the separate base-case overload that C++14 variadic templates required. Because the non-taken branch is never instantiated, the recursive call process(rest...) inside the else clause is only instantiated when rest is non-empty — the compiler never tries to form a zero-argument call:
// C++17: single function handles both base and recursive cases
template<typename Head, typename... Tail>
void print_all(Head&& h, Tail&&... tail) {
std::cout << h;
if constexpr (sizeof...(tail) > 0) {
std::cout << ", ";
print_all(std::forward<Tail>(tail)...); // only instantiated when tail is non-empty
}
}
print_all(1, "hello", 3.14); // 1, hello, 3.14You can combine this with fold expressions for even more concise packs, but the explicit recursive form is preferable when per-element logic is complex or the element type must be inspected:
// C++17: type-discriminated accumulation over a pack
template<typename T, typename... Rest>
auto sum_ints(T first, Rest... rest) {
auto acc = std::is_integral_v<T> ? first : T{};
if constexpr (sizeof...(rest) > 0)
acc += sum_ints(rest...);
return acc;
}The depth of recursion equals the pack length, so extremely long packs may hit the instantiation depth limit; prefer fold expressions when all elements are treated identically.
Interaction with structured bindings
Structured bindings and if constexpr work together but the binding names are not usable in constant expressions. A binding introduced by auto [a, b] = ... is not a constexpr variable, so it cannot appear as the condition of if constexpr. Use the bound values inside the body of a branch instead:
// C++17
template<typename Pair>
void inspect_pair(const Pair& p) {
auto [first, second] = p;
// if constexpr (first == 0) { } // ERROR: 'first' is not a constant expression
if constexpr (std::is_integral_v<typename Pair::first_type>) {
// safe to do integer-specific work with 'first' here
std::cout << "int key: " << first << "\n";
}
}For tuple-like decomposition across index sequences, combine std::get with an index loop and if constexpr on the element type:
// C++17
template<typename Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& t, std::index_sequence<Is...>) {
([&] {
using E = std::tuple_element_t<Is, Tuple>;
if constexpr (std::is_same_v<E, bool>)
std::cout << (std::get<Is>(t) ? "true" : "false");
else
std::cout << std::get<Is>(t);
if constexpr (Is + 1 < std::tuple_size_v<Tuple>)
std::cout << ", ";
}(), ...);
}
template<typename... Ts>
void print_tuple(const std::tuple<Ts...>& t) {
print_tuple_impl(t, std::index_sequence_for<Ts...>{});
}Type-based dispatch with constexpr-if chains
Ordered if constexpr chains act as a compile-time type switch. The first branch whose condition is true is the only one instantiated; later branches may freely use operations that are invalid for the matched type:
// C++17
template<typename T>
std::string to_string_adaptive(const T& val) {
if constexpr (std::is_same_v<T, std::string>)
return val;
else if constexpr (std::is_same_v<T, bool>)
return val ? "true" : "false";
else if constexpr (std::is_arithmetic_v<T>)
return std::to_string(val);
else if constexpr (requires { val.to_string(); }) // C++20 requires-expression
return val.to_string();
else {
static_assert(sizeof(T) == 0, "to_string_adaptive: no conversion path for T");
}
}Unlike std::visit or virtual dispatch, this produces zero runtime overhead — each instantiation is a single-path function. The order matters: place cheap, common-type checks first and the catch-all static_assert last.
Constexpr-if and concept constraints
Concepts and if constexpr are complementary, not competing. Use a concept constraint on the function signature to reject callsites that provide completely wrong types; use if constexpr inside the body to fine-tune behaviour among types that all satisfy the constraint:
// C++20
template<std::ranges::range R>
void dump(const R& r) {
if constexpr (std::ranges::sized_range<R>)
std::cout << "size=" << std::ranges::size(r) << "\n";
if constexpr (requires { r.c_str(); }) // narrow further: only std::string-like
std::cout << "(string: " << r.c_str() << ")\n";
else
for (auto& e : r) std::cout << e << " ";
std::cout << "\n";
}A requires-expression inside if constexpr checks syntactic validity at the call site for that specific instantiation — it does not affect overload resolution or add a constraint to the function template itself. To make a hard API guarantee, put the constraint on the template signature; to choose an implementation path, use if constexpr inside:
// C++20: concept gates the API; if constexpr chooses the fastest algorithm
template<std::integral T>
T next_power_of_two(T n) {
if constexpr (sizeof(T) <= 4)
return T{1} << (32 - __builtin_clz(n - 1)); // 32-bit path
else
return T{1} << (64 - __builtin_clzll(n - 1)); // 64-bit path
}Discarded statement rules: what still must parse
The discarded branch must still be syntactically and semantically valid as a dependent expression — it is not a text macro. The compiler parses the branch, performs name lookup for non-dependent names, and checks non-dependent type conversions; only dependent constructs (those involving template parameters) escape instantiation-time checking:
// C++17
template<typename T>
void mixed(T val) {
if constexpr (std::is_integral_v<T>) {
val.serialize(); // OK in discarded branch: 'serialize' depends on T
} else {
val++; // OK: operator++ depends on T
}
}
void not_a_template() {
if constexpr (false) {
int* p = "hello"; // ERROR: ill-formed regardless — not in a template
undeclared_fn(); // ERROR: non-dependent lookup still runs
}
}Non-dependent names in a discarded branch are resolved at parse time. Calls to undeclared functions, invalid non-dependent conversions, and access to non-existent global members all remain errors even when the condition is a known false constant. The discarded branch is immune only to instantiation of dependent expressions, not to parsing or non-dependent semantic checks.
A useful mental model: think of the discarded branch as an uncalled function template — the body is parsed and non-dependent names resolved, but no concrete code is generated for that set of template arguments.
Practical: serialization framework skeleton
A layered if constexpr dispatch in a serialize function covers the most common C++ types with no virtual dispatch and no runtime overhead. The pattern scales by adding new concept predicates at the top of the chain:
// C++20 serialization dispatcher
#include <concepts>
#include <ranges>
#include <string>
#include <type_traits>
#include <vector>
template<typename T>
concept StringLike = std::convertible_to<T, std::string_view>;
template<typename T>
concept TupleLike = requires { std::tuple_size<T>::value; };
template<typename T>
concept Serializable = requires(const T& v, std::vector<std::byte>& buf) {
{ v.serialize(buf) };
};
template<typename T>
void serialize(const T& val, std::vector<std::byte>& buf) {
if constexpr (Serializable<T>) {
// T knows how to serialize itself
val.serialize(buf);
} else if constexpr (StringLike<T>) {
// write 4-byte length prefix then UTF-8 bytes
std::string_view sv{val};
uint32_t len = static_cast<uint32_t>(sv.size());
auto* lp = reinterpret_cast<const std::byte*>(&len);
buf.insert(buf.end(), lp, lp + 4);
auto* dp = reinterpret_cast<const std::byte*>(sv.data());
buf.insert(buf.end(), dp, dp + sv.size());
} else if constexpr (std::ranges::range<T>) {
// write element count, then recurse for each element
uint32_t count = static_cast<uint32_t>(std::ranges::distance(val));
auto* cp = reinterpret_cast<const std::byte*>(&count);
buf.insert(buf.end(), cp, cp + 4);
for (const auto& elem : val)
serialize(elem, buf); // recursive dispatch — C++17/20
} else if constexpr (TupleLike<T>) {
// unpack tuple/pair at compile time
std::apply([&buf](const auto&... fields) {
(serialize(fields, buf), ...);
}, val);
} else if constexpr (std::is_trivially_copyable_v<T>) {
// POD fallback: raw byte copy
auto* p = reinterpret_cast<const std::byte*>(&val);
buf.insert(buf.end(), p, p + sizeof(T));
} else {
static_assert(sizeof(T) == 0,
"serialize(): T is not serializable, a range, tuple-like, or trivially copyable");
}
}Each branch is a distinct, independently readable strategy. Adding a new type category means prepending a new else if constexpr check; no existing branches change. The static_assert at the end gives a clear error instead of a silent wrong-type instantiation.
See Also
- constexpr Functions — compile-time computation context
- Concepts — cleaner alternative for type dispatch
- consteval and constinit — compile-time-only functions
- Template Type Traits — the predicates powering if constexpr conditions
- Tag Dispatch and SFINAE — the patterns if constexpr replaces