Migrating a Template Library from SFINAE to C++20 Concepts
A small describe() library with three enable_if overloads, migrated to concepts in three steps, keeping the runtime output byte-identical. The migration is mechanical until subsumption, where the rules are not what they look like. Every error message quoted here is a real one, from GCC 13 or Clang 18.
The library before
The library is one header, describe.hpp, with three overloads of describe: one for integral types, one for floating point types, one for text-like types. The SFINAE version expresses each condition with std::enable_if_t as a defaulted template parameter. The condition is checked during substitution, and when it fails, the overload is removed from the candidate set without an error. That removal is all SFINAE ever meant.
#pragma once
#include <string>
#include <string_view>
#include <type_traits>
namespace sfinae_lib {
template <typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
std::string describe(T v) {
return "integral: " + std::to_string(v);
}
template <typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
std::string describe(T v) {
return "floating: " + std::to_string(v);
}
template <typename T,
std::enable_if_t<!std::is_arithmetic_v<T> &&
std::is_convertible_v<T, std::string_view>,
int> = 0>
std::string describe(T v) {
return "text: " + std::string(std::string_view(v));
}
} // namespace sfinae_lib
The caller is the same for both versions. It prints three lines, one per overload:
#include <iostream>
#include <string>
#include "describe.hpp"
int main() {
std::cout << describe(42) << '\n';
std::cout << describe(2.5) << '\n';
std::cout << describe(std::string("hello")) << '\n';
return 0;
}
Tested with GCC 13.3.0 and Clang 18.1.3 on Ubuntu 24.04, at -std=c++20. Both versions below compile on both compilers, and the program output is identical in all four combinations. That output is the migration's contract: the refactor changes how overloads are selected, never what the program prints.
Step 1: a requires clause, nothing else moves
The smallest possible migration changes one overload and nothing else. The enable_if_t parameter disappears, and a requires clause after the template parameter list carries the same condition. The function body is untouched.
template <typename T>
requires std::integral<T>
std::string describe(T v) {
return "integral: " + std::to_string(v);
}
Two things change in how the compiler treats this overload. First, the condition is now a named concept, std::integral, instead of a trait expression buried in a defaulted parameter. Second, the constraint participates in overload resolution as a constraint, not as a substitution failure. When the constraint is not satisfied, the compiler can say so by name, which is the difference the error section below shows. Nothing about the call sites changes.
Step 2: constrained template parameters
The second spelling puts the concept directly on the template parameter. The floating point overload becomes:
template <std::floating_point T>
std::string describe(T v) {
return "floating: " + std::to_string(v);
}
This is the same constraint as a requires clause, written more briefly. It reads as a declaration of intent: this overload takes floating point types. One rule matters when you mix spellings across a header: a function template that is redeclared must use the same syntactic form in each declaration. A definition and a forward declaration that spell the same constraint differently are not the same declaration. Keep one spelling per overload across the whole header, and the rule never bites.
Step 3: a named concept and a trailing requires clause
The text overload needs a condition that no standard concept expresses directly: not arithmetic, and convertible to std::string_view. That condition becomes a named concept of its own, and the overload carries it in a trailing requires clause after the parameter list:
template <typename T>
concept TextLike = !std::is_arithmetic_v<T> &&
std::convertible_to<T, std::string_view>;
template <typename T>
std::string describe(T v) requires TextLike<T> {
return "text: " + std::string(std::string_view(v));
}
Concepts are defined at namespace scope, they cannot be recursive, and a concept cannot itself be constrained. Those limits are part of the definition: a concept is a named predicate over types, nothing more, and the compiler needs that simplicity to compare constraints, which is the next section's subject.
The complete header after the migration
Assembled, the migrated header uses all three spellings, one per overload. This is the file that was compiled and run:
#pragma once
#include <concepts>
#include <string>
#include <string_view>
#include <type_traits>
namespace concepts_lib {
// Spelling 1: requires clause after the template parameter list.
template <typename T>
requires std::integral<T>
std::string describe(T v) {
return "integral: " + std::to_string(v);
}
// Spelling 2: constrained template parameter.
template <std::floating_point T>
std::string describe(T v) {
return "floating: " + std::to_string(v);
}
// Spelling 3: named concept + trailing requires clause.
template <typename T>
concept TextLike = !std::is_arithmetic_v<T> &&
std::convertible_to<T, std::string_view>;
template <typename T>
std::string describe(T v) requires TextLike<T> {
return "text: " + std::string(std::string_view(v));
}
} // namespace concepts_lib
Proving nothing changed
Both versions compile on both compilers at -std=c++20, and both print the same three lines:
integral: 42
floating: 2.500000
text: hello
The 2.500000 is std::to_string formatting a double, identical in both versions. The migration contract holds: overload selection changed, program behavior did not. Keep a program like this one next to any real migration. The constraint changes are easy to get subtly wrong, and the only reliable check is the output, not the shape of the code.
The error messages, honestly
The usual story about concepts is that error messages get shorter. For this library, that story is false. Calling describe with a type no overload accepts, a std::vector<int>, produces these line counts for the full compiler output:
| Compiler | SFINAE version | Concepts version | | GCC 13.3.0 | 26 lines | 44 lines | | Clang 18.1.3 | 13 lines | 34 lines |
The concepts errors are longer on both compilers. What changes is what the error says. The SFINAE versions bury the reason in template machinery. GCC reports, once per overload:
error: no type named 'type' in 'struct std::enable_if<false, int>'
Clang reports the trait expression that failed, which is readable but still exposes the mechanism:
note: candidate template ignored: requirement
'std::is_integral_v<std::vector<int, std::allocator<int>>>'
was not satisfied [with T = std::vector<int>]
The concepts versions name the unsatisfied constraint. GCC says, for each candidate:
note: constraints not satisfied
And Clang gives the reason chain, one note per overload, naming the concept that failed:
note: because 'std::vector<int>' does not satisfy 'integral'
note: because 'std::vector<int>' does not satisfy 'floating_point'
note: because 'std::vector<int>' does not satisfy 'TextLike'
The benefit is not brevity. It is that the error names the constraint the caller violated, in the vocabulary of the library, instead of exposing the machinery that removed the overload. Longer output, better first line. Judge the migration on that trade, not on the promise of shorter errors.
Subsumption: the part that changes behavior
So far the migration has been spelling. Subsumption is where concepts change overload resolution itself. When two overloads both accept a call, the compiler prefers the one whose constraints subsume the other's. A constraint subsumes another when its normalized form contains all of the other's atomic constraints, plus at least one more. Normalization breaks a constraint into conjunctions and disjunctions of atomic constraints, and subsumption compares those atoms by identity of form, not by logical equivalence. Two constraints that mean the same thing in different words do not subsume each other.
This program tests the rule directly. SmallIntegral adds a size condition to std::integral, so it subsumes the plain std::integral constraint, and the more constrained overload should win for small types only:
#include <concepts>
#include <iostream>
#include <string>
template <typename T>
concept SmallIntegral = std::integral<T> && (sizeof(T) <= 4);
template <std::integral T>
std::string pick(T) {
return "generic integral";
}
template <SmallIntegral T>
std::string pick(T) {
return "small integral";
}
int main() {
std::cout << pick(42) << '\n';
std::cout << pick(42L) << '\n';
std::cout << pick(static_cast<short>(7)) << '\n';
return 0;
}
The output, identical on both compilers:
small integral
generic integral
small integral
42 is an int, four bytes, so the constrained overload wins. 42L is a long, eight bytes on this platform, so it falls through to the generic overload. The short is small, so the constrained overload wins again. That is subsumption doing work SFINAE cannot do: with enable_if, two overloads that both match are ambiguous, and the programmer orders them by hand with extra negated conditions. With concepts, the more constrained overload wins by rule.
The identity part of the rule deserves a warning. Subsumption compares atomic constraints by identical form. A constraint written as N > 0 does not subsume one written as N >= 0, even though the first implies the second logically. The compiler compares the atoms as written, not what they mean. Two constraints that look equivalent but are spelled differently are, to the compiler, unrelated.
The identity trap
That warning has a concrete failure mode. Two concepts built from different source expressions that mean the same thing do not subsume each other, and an overloaded call between them is ambiguous:
#include <concepts>
#include <iostream>
#include <string>
#include <type_traits>
// Two concepts that LOOK equivalent but are built from different
// source expressions: their atomic constraints are not identical,
// so neither subsumes the other and the call below is ambiguous.
template <typename T>
concept IsIntegralA = std::integral<T>;
template <typename T>
concept IsIntegralB = std::is_integral_v<T>;
template <IsIntegralA T>
std::string check(T) {
return "A";
}
template <IsIntegralB T>
std::string check(T) {
return "B";
}
int main() {
std::cout << check(42) << '\n';
return 0;
}
Both compilers reject the call. GCC says:
error: call of overloaded 'check(int)' is ambiguous
Clang says:
error: call to 'check' is ambiguous
The tempting fix is to define B as just IsIntegralA<T>, making the two concepts literally the same. That fix was tested, and it fails on both compilers with the same ambiguity error. Same normalized constraint set on both sides means neither overload is more constrained than the other, so there is still no winner. The fix that works defines B through A and adds a distinct atomic constraint, so B genuinely subsumes A:
template <typename T>
concept IsIntegralA = std::integral<T>;
template <typename T>
concept SmallIntegralB = IsIntegralA<T> && (sizeof(T) <= 4);
template <IsIntegralA T>
std::string check(T) {
return "A";
}
template <SmallIntegralB T>
std::string check(T) {
return "B";
}
int main() {
std::cout << check(42) << '\n';
std::cout << check(42L) << '\n';
return 0;
}
This version compiles on both compilers and prints:
B
A
The lesson generalizes: when two overloads are ambiguous, the repair is never to restate the same constraint in a second spelling. The repair is to make one constraint strictly stronger than the other, through the first concept plus one more atomic condition. Concepts reward a hierarchy of named constraints. They punish two names for one idea.
Migration checklist
Migrate one overload at a time, and rebuild the test program after each step. Keep the test program that pins the output, and run it on every compiler the library supports, because subsumption and ambiguity are decided per compiler and the answers agree only when the constraints are written carefully. Name a concept when a condition is reused or when it names an idea the library owns, like TextLike. Keep one spelling per overload across declarations and definitions. When two overloads turn ambiguous, check whether their constraints are built from different source expressions before assuming the logic is wrong. And read the constraint section of the cppreference constraints page before leaning on subsumption in a public header, because the normalization rules decide cases that intuition gets wrong.
Frequently asked questions
Do all overloads have to migrate at once?
No. A header can hold enable_if overloads next to constrained overloads, and overload resolution handles the mix. Migrate one overload, rebuild, run the tests, and move to the next. The intermediate states are ordinary C++.
Are the concepts errors always longer?
Not always. The counts above are for one bad call against this three-overload library. The pattern that holds generally is the one shown: concepts errors name the violated constraint, SFINAE errors expose the removal machinery. Whether the output is longer depends on how many candidates the compiler has to explain.
Is there a fourth spelling?
Yes. C++20 also allows abbreviated function templates, where the parameter itself is written as a constrained placeholder, as in std::string describe(std::integral auto v). It was not used in this migration, and no code using it was tested here. It is the same constraint mechanism in a shorter form, and the constraint order rules on the cppreference page cover where it sits relative to the other spellings.
What comes next in this course?
The previous guide built the module system this course uses for larger projects. See C++20 Modules with CMake 3.28 and Ninja. The next guide in the course covers std::expected error-handling pipelines. See C++23 std::expected Error-Handling Pipelines.
Source
- cppreference, Constraints and concepts (since C++20) — constraint normalization, subsumption by atomic-constraint identity, constraint order, redeclaration rules, and concept definition rules.
Facts verified 2026-11-04. Every code listing on this page was compiled and run with GCC 13.3.0 and Clang 18.1.3 at -std=c++20 on Ubuntu 24.04, including the programs that fail: the error messages and line counts are quoted from the actual compiler output, and the failing programs are labeled as failing.
Keep Exploring
Keep exploring the docs, samples, and free tools to continue your evaluation.
Score SPF, DKIM, DMARC, MTA-STS, TLS-RPT, and BIMI for any domain.
Review grounded audit, compare, fix-plan, and report excerpts before you wire the API into anything.
Jump from the workflow page into the quickstart, endpoint guides, and full OpenAPI reference.