C++20 Concepts: Writing Readable Template Constraints with concept and requires
Key takeaways
How C++20 concepts replace enable_if with named constraints: syntax forms, standard concepts, requires expressions, subsumption, the compiler errors you will actually see, and a migration path.
This guide is the reference and production playbook for C++20 concepts: the syntax forms, the standard-library concepts you will actually use, subsumption and overload resolution, the compiler errors you will see (reproduced with GCC 10), and a migration path off enable_if. If you want a slower walkthrough with more worked examples first, read C++20 Concepts: Making Template Error Messages Readable. This article assumes you know why concepts exist and focuses on using them correctly.
Compiler support: GCC 10+ and Clang 10+ with -std=c++20, MSVC from Visual Studio 2019 16.8 (/std:c++20 or /std:c++latest). GCC 6 to 9 only had the older Concepts TS behind -fconcepts, with a different syntax (concept bool); code written against it does not compile as C++20. For feature detection, __cpp_concepts >= 201907L covers the language feature and __cpp_lib_concepts >= 202002L the <concepts> header (GCC 10.3 reports exactly those values).
Why concepts: the error-message problem
template<typename T>
T add(T a, T b) { return a + b; }
int main() {
add("hello", "world"); // error deep inside the body: invalid operands to binary +
}
Without a constraint, the compiler accepts the call, instantiates the body, and fails on the line that uses +. In a real library that line sits several layers of templates deep, and the diagnostic walks through all of them.
Before C++20 the fix was SFINAE, usually std::enable_if or void_t tricks: make substitution fail in the declaration so the overload silently disappears. It works, but the constraint is hidden in a default template argument, and when no overload survives, the compiler can only say “no matching function” and list every candidate with its substitution failure.
A concept is a named compile-time predicate. The compiler checks it before instantiating the body, and when it fails, the error names the concept, the type and the requirement that failed. The constraint becomes part of the declaration a reader sees, instead of an inference buried in the body.
Basic syntax: concept and requires
A concept is declared like a variable template whose value is always bool:
#include <concepts>
template<typename T>
concept Integral64 = std::integral<T> && sizeof(T) == 8;
There are four ways to apply a constraint:
// 1. type-constraint in the template parameter list
template<std::integral T>
T twice(T x) { return x * 2; }
// 2. requires clause after the template parameter list
template<typename T>
requires std::integral<T>
T twice2(T x) { return x * 2; }
// 3. trailing requires clause
template<typename T>
T twice3(T x) requires std::integral<T> { return x * 2; }
// 4. abbreviated function template (constrained auto parameter)
auto twice4(std::integral auto x) { return x * 2; }
Forms 1 to 3 accept the same set of calls. Form 4 is subtly different when there is more than one parameter: every auto parameter is its own template parameter. auto add(std::integral auto a, std::integral auto b) accepts add(1, 2L) (an int and a long), whereas template<std::integral T> T add(T a, T b) fails deduction for the same call. Pick form 4 for small leaf functions where independent types are fine, and use a named T whenever parameters must share a type.
The requires clause (forms 2 and 3) is what you need once a constraint relates several parameters, such as requires std::convertible_to<U, T>. The trailing form is also the only way to constrain a non-template member function of a class template, for example void reserve(std::size_t) requires has_reserve<Storage>;.
Standard concepts worth knowing
Reach for a standard concept whenever the constraint is a property of the language (is it an integer, copyable, callable). Write a custom concept only when the constraint is about your own API.
| Group | Concepts (in <concepts> unless noted) |
|---|---|
| Type category | integral, signed_integral, unsigned_integral, floating_point |
| Relationships | same_as, derived_from, convertible_to, common_with |
| Object lifecycle | default_initializable, move_constructible, copy_constructible, movable, copyable, semiregular, regular |
| Comparison | equality_comparable, totally_ordered, three_way_comparable (<compare>) |
| Callables | invocable, predicate, relation, strict_weak_order |
| Iterators and ranges | input_iterator, forward_iterator, random_access_iterator, contiguous_iterator, sentinel_for (<iterator>); ranges::range, ranges::sized_range, ranges::random_access_range (<ranges>) |
A few details that trip people up:
same_as<int, const int>is false. It demands identical types, which is usually too strict for return types (see section 6).derived_from<D, B>is not the same asis_base_of_v<B, D>: it also requiresD*to convert toB*, so a private or ambiguous base fails it.is_base_of_v<Base, Priv>is true forstruct Priv : private Base {}, whilederived_from<Priv, Base>is false.default_initializable<T>is stricter thanis_default_constructible_v<T>: it also requiresT t;to be valid. Forconst int, the trait is true and the concept is false.- For comparators passed to sort-like algorithms, the right concept is
strict_weak_order<F, T, T>, notpredicate<F, T>(which takes one argument). - For “any container”, prefer
std::ranges::range<R>orsized_range<R>over writing your own: they work with arrays,std::spanand views, and they are what the standard algorithms use.
#include <concepts>
#include <iterator>
#include <list>
#include <vector>
template<std::input_iterator It>
const char* process(It, It) { return "input"; }
template<std::random_access_iterator It>
const char* process(It, It) { return "random_access"; }
// process(vec.begin(), vec.end()) -> "random_access"
// process(list.begin(), list.end()) -> "input"
Both overloads are viable for a vector iterator, and the compiler picks the random_access_iterator one because that concept subsumes input_iterator (section 5).
Writing custom concepts
Custom concepts are checked structurally: a type satisfies one if the expressions compile, whether or not it was written with the concept in mind. That is what lets you constrain third-party types.
A container concept that does not over-constrain
#include <concepts>
#include <cstddef>
#include <iostream>
#include <iterator>
#include <vector>
template<typename T>
concept Container = requires(T c) {
typename T::value_type;
{ c.size() } -> std::convertible_to<std::size_t>;
{ c.begin() } -> std::input_iterator;
{ c.end() } -> std::sentinel_for<decltype(c.begin())>;
{ c.empty() } -> std::convertible_to<bool>;
};
template<Container C>
void print_size(const C& c) { std::cout << "Size: " << c.size() << '\n'; }
int main() {
std::vector<int> v{1, 2, 3};
print_size(v); // Size: 3
int arr[] = {1, 2, 3};
static_assert(!Container<decltype(arr)>); // no members on a C array
}
A common first draft uses { c.begin() } -> std::same_as<typename T::iterator> and { c.size() } -> std::same_as<std::size_t>. Both are traps: a container whose size() returns std::uint32_t, or whose begin() returns a different but perfectly usable iterator type, fails the concept for no good reason. Constrain return types by what you do with them (convertible_to, an iterator concept), not by pinning one exact type.
Instance and static requirements together
template<typename T>
concept Serializable = requires(const T& obj, std::ostream& os, std::istream& is) {
{ obj.serialize(os) } -> std::same_as<void>;
{ T::deserialize(is) } -> std::same_as<T>;
};
struct Point {
int x = 0, y = 0;
void serialize(std::ostream& os) const { os << x << ' ' << y; }
static Point deserialize(std::istream& is) { Point p; is >> p.x >> p.y; return p; }
};
template<Serializable T> void save(const T& o, std::ostream& os) { o.serialize(os); }
template<Serializable T> T load(std::istream& is) { return T::deserialize(is); }
Note the parameter is const T& obj. If you declare it as T obj, the concept checks a non-const object, and a type whose serialize is not const passes the concept but then fails inside save, which takes const T&. The concept should test the same value category and constness the function body uses.
Plain boolean combinations
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
When the constraint is “concept X or concept Y”, write it like this rather than with a requires expression. It is shorter, and subsumption (section 5) only reasons through named concepts combined with && and ||.
requires expressions
A requires expression takes hypothetical parameters and a body of requirements, and is true if every requirement would compile. Nothing inside is evaluated. There are four kinds of requirement:
template<typename T>
concept Example = requires(T t) {
t.reset(); // simple: expression is valid
typename T::value_type; // type: nested name exists
{ t.size() } -> std::convertible_to<std::size_t>; // compound: valid + result constraint
{ t.swap(t) } noexcept; // compound: must be noexcept
requires sizeof(T) <= 64; // nested: a bool constant expression
};
Two syntax rules produce most of the confusing errors here:
- After
->you must write a type-constraint (a concept, with its first argument implied), not a type.{ a + a } -> T;is rejected by GCC witherror: return-type-requirement is not a type-constraint. Write-> std::same_as<T>or-> std::convertible_to<T>. t.size();inside a requires expression only checks that the call compiles.requires t.size() > 0;would need a constant expression and is almost never what you want.
A requires expression can also be used directly in if constexpr, which is the modern replacement for tag dispatch when you need different code paths rather than accept/reject:
template<typename T>
std::size_t len(const T& t) {
if constexpr (requires { t.size(); }) return t.size();
else return 1;
}
// len(std::string("abc")) == 3, len(42) == 1
requires requires (a requires clause whose condition is an ad-hoc requires expression) is legal, as in template<typename T> requires requires(T t) { t.foo(); } void f(T);, but if you write it twice for the same check, give it a name.
Composition and subsumption
When two constrained overloads are both viable, the compiler picks the one whose constraints subsume the other’s. Subsumption is computed by normalizing each constraint into atomic pieces joined by && and ||, then checking whether one implies the other.
The important rule: two atomic constraints are the same only if they come from the same expression in the same place in the source. That is why this works:
template<typename T> concept HasFoo = requires(T t) { t.foo(); };
template<typename T> concept HasFooBar = HasFoo<T> && requires(T t) { t.bar(); };
template<HasFoo T> int g(T) { return 1; }
template<HasFooBar T> int g(T) { return 2; } // more constrained, wins when both match
and why this does not:
template<typename T> concept A = requires(T t) { t.foo(); };
template<typename T> concept B = requires(T t) { t.foo(); }; // same text, different atom
template<A T> int f(T) { return 1; }
template<B T> int f(T) { return 2; }
struct S { void foo(); };
int main() { return f(S{}); }
GCC 10 reports:
error: call of overloaded 'f(S)' is ambiguous
note: candidate: 'int f(T) [with T = S]'
note: candidate: 'int f(T) [with T = S]'
The same trap applies to raw traits. With requires std::is_integral_v<T> on one overload and requires std::is_integral_v<T> && (sizeof(T) == 4) on another, f(1) is ambiguous in GCC 10, because the two is_integral_v<T> expressions are separate atoms. Replace the trait with the concept std::integral<T> in both declarations and the same call resolves to the more constrained overload. Name a check once as a concept, and build refinements from that name.
Common errors and fixes
All diagnostics below were reproduced with GCC 10.3 and -std=c++20.
”use of function … with unsatisfied constraints”
template<std::integral T>
T add(T a, T b) { return a + b; }
int main() { add(1.5, 2.5); }
error: use of function 'T add(T, T) [with T = double]' with unsatisfied constraints
note: constraints not satisfied
note: the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false'
Clang words the same failure as candidate template ignored: constraints not satisfied [with T = double] followed by because 'double' does not satisfy 'integral'. When GCC stops early with note: set '-fconcepts-diagnostics-depth=' to at least 2 for more detail, pass that flag: it expands the chain through nested concepts.
A concept that rejects types you expected to pass
template<typename T>
concept Addable = requires(T a, T b) { { a + b } -> std::same_as<T>; };
static_assert(Addable<int>);
static_assert(!Addable<short>); // short + short is int
static_assert(!Addable<char>); // char + char is int
This is the most common way I have seen a concept quietly break a generic numeric API. The concept reads naturally, the tests use int and double, and months later someone calls it with std::int16_t and gets a constraint failure. Integer promotion means the result of a + b is never short or char. std::convertible_to<T> accepts them; so would checking only that a + b is valid.
”expression must be enclosed in parentheses”
template<typename T> requires sizeof(T) > 4 // error
void f(T);
A requires clause only accepts primary expressions (names, concept-ids, literals, parenthesized expressions) joined by && and ||. Write requires (sizeof(T) > 4). The same applies to requires !std::integral<T>, which must be requires (!std::integral<T>).
”constraint … has type ‘int’, not ‘bool’”
template<typename T> concept HasValue = T::value;
struct X { static constexpr int value = 1; };
static_assert(HasValue<X>);
error: constraint 'T::value [with T = X]' has type 'int', not 'bool'
Atomic constraints must be exactly bool, with no implicit conversion. Write bool(T::value) or T::value != 0.
”deduced initializer does not satisfy placeholder constraints”
std::integral auto x = 5; // OK
std::integral auto y = 5.0; // error: deduced initializer does not satisfy placeholder constraints
Constrained auto works for variables and return types as well as parameters. It is a cheap way to document and check the type of an intermediate value without spelling it out.
A concept is not a type
Container x; is an error; a concept can only appear where a constraint is expected. You also cannot specialize a concept, constrain a concept (template<std::integral T> concept C = ... is ill-formed), or make it recursive.
Constraints check the wrong constness
A concept declared as requires(T t) { t.size(); } tests a non-const T. If the constrained function takes const T& and calls size(), a type with only a non-const size() passes the constraint and then fails inside the body, which is the exact error concepts were supposed to prevent. I treat this as the first thing to check when a concept “passes but the body still breaks”: the requirement parameters should mirror how the body uses the object (const T& for read-only use).
Production patterns
Constraining class templates
#include <concepts>
#include <iostream>
#include <iterator>
#include <vector>
template<typename T>
concept Printable = requires(std::ostream& os, const T& t) {
{ os << t } -> std::same_as<std::ostream&>;
};
template<typename T>
requires Printable<T> && std::copyable<T>
class LoggedContainer {
std::vector<T> items;
public:
void add(const T& item) {
items.push_back(item);
std::cout << "Added: " << item << '\n';
}
template<std::input_iterator It, std::sentinel_for<It> S>
requires std::convertible_to<std::iter_reference_t<It>, const T&>
void addRange(It first, S last) {
for (; first != last; ++first) add(*first);
}
std::size_t size() const { return items.size(); }
};
int main() {
LoggedContainer<int> c;
c.add(42);
std::vector<int> v{1, 2, 3};
c.addRange(v.begin(), v.end());
std::cout << "Size: " << c.size() << '\n'; // Size: 4
}
Constrain the class with what every member needs, and individual member templates with what only they need. Instantiating LoggedContainer<NoPrint> fails at the declaration with template constraint failure for ... class LoggedContainer and the required expression '(os << t)' is invalid, instead of an error inside add.
Concepts as documentation
template<Drawable T> void render(const T&, Canvas&) states the contract in the signature. Keep concept names about capabilities (Drawable, Hashable) rather than about the types you happen to pass today.
Keep concepts small and named
Big anonymous requires blocks on every function do not compose and do not subsume. A small set of named concepts, each built from the previous with &&, gives readable errors and lets you add specialized overloads later without ambiguity.
SFINAE vs concepts
| Aspect | SFINAE (enable_if) | Concepts (C++20) |
|---|---|---|
| Where the constraint lives | Default template argument or return type | Declaration, in a named form |
| Error when nothing matches | ”no matching function” plus per-candidate substitution failures | Names the unsatisfied concept and requirement |
| Overload ordering | Must make conditions mutually exclusive by hand | Subsumption picks the most constrained overload |
| Minimum standard | C++11 | C++20 |
Keep SFINAE only in headers that must still compile as C++17 or earlier. A common transitional pattern is a macro that expands to a requires clause when __cpp_concepts is defined and to enable_if otherwise, but it doubles the testing surface, so drop it once the minimum standard allows.
Migrating from enable_if
Concepts and SFINAE coexist in the same translation unit and even the same overload set, so migrate one function at a time.
- Find the constraints:
grep -rn "enable_if\|void_t\|is_.*_v<" src/ - Map traits to concepts, checking the semantic differences:
| Old trait | Concept | Difference |
|---|---|---|
is_integral_v<T> | std::integral<T> | none |
is_floating_point_v<T> | std::floating_point<T> | none |
is_same_v<T, U> | std::same_as<T, U> | concept is symmetric for subsumption |
is_convertible_v<From, To> | std::convertible_to<From, To> | concept also requires explicit conversion |
is_base_of_v<Base, T> | std::derived_from<T, Base> | concept rejects private/ambiguous bases |
is_default_constructible_v<T> | std::default_initializable<T> | concept also requires T t; |
- Replace overload sets together. If two overloads were made mutually exclusive with
enable_if<cond>andenable_if<!cond>, convert them to a general overload and a more constrained one; subsumption removes the need for the negated condition. - Raise the language level in the build. In CMake, prefer a per-target requirement over the global variable:
target_compile_features(mylib PUBLIC cxx_std_20)
PUBLIC matters when concepts appear in your public headers: consumers must compile as C++20 too.
When migrating, behavior changes show up exactly where the table’s “Difference” column is non-empty. A function that accepted a privately derived type through is_base_of will reject it after switching to derived_from, which is usually correct but is still a change your tests should cover.
Related Articles
- C++20 Concepts: Making Template Error Messages Readable: a slower introduction with more worked examples
- C++ Concepts and Constraints (Quick Reference)
- C++ SFINAE Explained
- C++ if constexpr
- C++ Type Traits
- C++20 Ranges Introduction
FAQ
Should I use concepts or SFINAE (enable_if)?
Use concepts for any code that can require C++20. They give shorter errors that name the failed requirement and they order overloads by subsumption. Keep enable_if only for headers that must still compile as C++17 or earlier.
Why does my type not satisfy my concept even though the code compiles?
Usually the concept is stricter than the code: same_as<T> on a + b rejects short and char because they promote to int, and an exact same_as on begin() rejects compatible iterator types. Use convertible_to or an iterator concept. GCC shows more detail with -fconcepts-diagnostics-depth=2.
Why are two overloads constrained by different concepts ambiguous?
Subsumption only works when one concept is built from the other by name, for example B = A && extra. Two concepts with identical but separately written requires expressions are unrelated to the compiler, so GCC reports call of overloaded 'f(S)' is ambiguous.
What does “expression must be enclosed in parentheses” mean in a requires clause?
A requires clause accepts only primary expressions joined by && and ||. Write requires (sizeof(T) > 4), not requires sizeof(T) > 4.
Which compilers support concepts?
GCC 10 and Clang 10 with -std=c++20, and MSVC in Visual Studio 2019 16.8 or later. Check __cpp_concepts (201907L or later) and __cpp_lib_concepts (202002L) for feature detection.