How C++20 Concepts Simplify Generic Programming

Constraints in C++ define the type requirements. Before C++20 we used ststic_assert or SFINAE for that purpose.

static_assert requires a lot of discipline to put all type constraints. And SFINAE is always like a magic. It reminds me old joke: “When I wrote it, only God and I knew how it worked. Now, only God”.

Constraints as a type requirements

The main benefit of using constraints is that it gives a name to a type requirements. You define it once and then use across the whole project.

Should type be a class with curtain methods, members or has defined operations? Or non-type parameter fits a specific range? Define that in a concept in the simple way:

template<typename T>
concept HasToString = requires(T a)
{
    // Define the required method
    {a.to_string()} -> std::convertible_to<std::string>;
};

a here is a placeholder for an object, not a real one. And if on substitution of the type, the requires block is valid, than the type satisfies the concept.

When the concept is defined, use it instead of generic typename in the template declaration:

template <HasToString T> 
void print(const T&val){
  std::cout << val.to_string() << std::endl;
}

If the argument if print function doesn’t satisfy the concept, for example call print(10);, the compiler rises an error:

&lt;source>:37:8: error: no matching function for call to 'print(int)'
   37 |   print(10);
      |   ~~~~~^~~~
  • there is 1 candidate
    • candidate 1: 'template&lt;class T>  requires  HasToString&lt;T> void print(const T&amp;)'
      &lt;source>:31:6:
         31 | void print(const T&amp;val){
            |      ^~~~~
      • template argument deduction/substitution failed:
        • constraints not satisfied
          • &lt;source>: In substitution of 'template&lt;class T>  requires  HasToString&lt;T> void print(const T&amp;) [with T = int]':
          • required from here
            &lt;source>:37:8:   
               37 |   print(10);
                  |   ~~~~~^~~~
          • required for the satisfaction of 'HasToString&lt;T>' [with T = int]
            &lt;source>:7:9:   
                7 | concept HasToString = requires(T a)
                  |         ^~~~~~~~~~~
          • in requirements with 'T a' [with T = int]
            &lt;source>:7:23:   
                7 | concept HasToString = requires(T a)
                  |                       ^~~~~~~~~~~~~
                8 | {
                  | ~                      
                9 |     {a.to_string()} -> std::convertible_to&lt;std::string>;
                  |     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
               10 | };
                  | ~                      
          • the required expression 'a.to_string()' is invalid
            &lt;source>:9:17:
                9 |     {a.to_string()} -> std::convertible_to&lt;std::string>;
                  |      ~~~~~~~~~~~^~

The message clearly conveys what is violated. So, concepts make the generic code easy not only to read and write, but also diagnose the compilation errors. Now, the type requirements aren’t hidden somewhere in the function or class definition, but explicitly stated in the concept declaration.

Concepts and Overload Resolution

Have you noticed the beginning of the compiler error?

no matching function for call to 'print(int)'

This happens because a constrained function template whose requirements are not satisfied is not considered a viable overload.

In other words, concepts can affect overload resolution.

For example, we can define another concept that checks whether std::to_string(value) is valid for a type:

#include <concepts>
#include <string>

template <typename T>
concept HasStdToString = requires(T value)
{
    { std::to_string(value) } -> std::convertible_to<std::string>;
};

Now we can add another overload of print:

template <HasStdToString T>
void print(const T& value)
{
    std::cout << std::to_string(value) << '\n';
}

Now this call works:

print(10);

because int satisfies HasStdToString.

However, there is one important detail: a type may satisfy more than one concept. If two overloads are viable and neither is more constrained than the other, the call may become ambiguous.

For example, if a type has a member function to_string() and can also be passed to std::to_string(), both overloads may match.

To avoid that, you can explicitly define the priority:

template <HasToString T>
void print(const T& value)
{
    std::cout << value.to_string() << '\n';
}

template <typename T>
requires (!HasToString<T> && HasStdToString<T>)
void print(const T& value)
{
    std::cout << std::to_string(value) << '\n';
}

Here, the member to_string() overload has priority.
The std::to_string() overload is used only if HasToString<T> is not satisfied.

This makes overload resolution predictable.

Class Template Specialization

Concepts can also be used with class template specializations.

For that, you first need a primary template:

template <typename T>
class Bar;

The primary template declares that Bar<T> exists, but does not provide a general implementation.

Then you can provide constrained partial specializations for different concepts:

template <HasToString T>
class Bar<T>
{
public:
    explicit Bar(T value)
        : m_value(std::move(value))
    {}

    void print() const
    {
        std::cout << m_value.to_string() << '\n';
    }

private:
    T m_value;
};

And another specialization for types supported by std::to_string():

template <typename T>
requires (!HasToString<T> && HasStdToString<T>)
class Bar<T>
{
public:
    explicit Bar(T value)
        : m_value(std::move(value))
    {}

    void print() const
    {
        std::cout << std::to_string(m_value) << '\n';
    }

private:
    T m_value;
};

The primary template is required because partial specializations specialize an existing template. Without the primary template, there is nothing to specialize.

The !HasToString<T> part prevents ambiguity when a type satisfies both concepts.

Before C++20 concepts, similar behavior was usually implemented with SFINAE and std::enable_if. That required more boilerplate and was much harder to read.

First, we needed a trait that checks whether a type has a suitable to_string() member function:

#include <string>
#include <type_traits>
#include <utility>

template <typename T, typename = void>
struct has_to_string : std::false_type {};

template <typename T>
struct has_to_string<
    T,
    std::void_t<decltype(std::declval<const T&>().to_string())>
> : std::is_convertible<
        decltype(std::declval<const T&>().to_string()),
        std::string
    > {};

Then we needed to use that trait in a partial specialization:

template <typename T, typename Enable = void>
class Bar;

template <typename T>
class Bar<T, std::enable_if_t<has_to_string<T>::value>>
{
public:
    explicit Bar(T value)
        : m_value(std::move(value))
    {}

    void print() const
    {
        std::cout << m_value.to_string() << '\n';
    }

private:
    T m_value;
};

This works, but the requirement is now split across a helper trait, std::void_t, std::declval, and std::enable_if.

With concepts, the same requirement is expressed directly and given a readable name.

The important shift is this:

Before concepts, template requirements were often hidden inside traits, SFINAE expressions, or implementation details.

With concepts, requirements become part of the interface.

Concept Composition

Concepts are composable.

You can combine existing concepts with logical operators:

  • && for conjunction,
  • || for disjunction,
  • ! for negation.

This makes concept composition the preferred way to define new requirements. Instead of writing a new concept from scratch every time, first check what your project already defines and what the standard library provides.

In many cases, a new concept is just a combination of existing ones.

The standard library already provides many useful concepts, for example:

  • type-related concepts: std::same_as, std::convertible_to, std::derived_from,
  • iterator-related concepts: std::input_iterator, std::forward_iterator, std::random_access_iterator,
  • range-related concepts: std::ranges::range, std::ranges::input_range, std::ranges::view.

Concepts can also be combined with type traits. Since a concept is a compile-time Boolean predicate, you can use traits such as std::is_trivially_copyable_v<T> inside a concept definition.

For example, suppose we want a function print to accept any range whose elements are char. We can express that requirement with a CharRange concept:

#include &lt;concepts>
#include &lt;ranges>

template &lt;typename T>
concept CharRange =
    std::ranges::input_range&lt;T> &amp;&amp;
    std::same_as&lt;std::ranges::range_value_t&lt;T>, char>;

This concept says two things:

  • T must be an input range,
  • the referenced element type of the range must be char.

Concepts are especially visible in the ranges library, where requirements such as input_range, view, and iterator categories are part of the API itself.

If you want to see how those abstractions improve everyday iteration code, I cover that in How C++ Ranges Make Iteration Less Error-Prone.

Now the requirement can be reused:

template &lt;CharRange R>
void print(R&amp;&amp; range)
{
   std::string res(range.begin(), range.end());
   std::cout &lt;&lt; "constructed string:" &lt;&lt; res &lt;&lt; std::endl;
}

This approach avoids repetition. The requirement is defined once, given a clear name, and then reused across the project — or even used as a building block for other concepts.

Concepts become particularly useful when you start building generic abstractions of your own. Instead of documenting template requirements only in prose, the type system can express them directly.

A practical example is Constructing Views in Modern C++, where iterator, sentinel, range, and view requirements become part of the implementation itself.

Modern C++ Gets Easier When the Ideas Connect

Concepts are easier to understand when they are not treated as an isolated language feature. They connect directly to templates, ranges, iterators, overload resolution, and API design.

That is the kind of connection I explore in From Complexity to Essence in C++ — one focused C++ topic at a time, with practical examples and the reasoning behind the design.

Conclusion

Concepts did not make templates less powerful.

They made template requirements visible.

Before C++20, many requirements were hidden inside implementation details: static_assert, type traits, SFINAE expressions, std::enable_if, and long substitution errors that looked less like diagnostics and more like compiler archaeology.

With concepts, those requirements become part of the interface.

A function template no longer says only:

template <typename T>

It can say what it actually expects:

template <HasToString T>

That changes how generic code is read, written, and maintained.

Concepts help you:

  • give names to type requirements,
  • make overload resolution more intentional,
  • simplify class template specialization,
  • compose reusable requirements from smaller concepts,
  • and get clearer compiler diagnostics when a type does not satisfy the expected interface.

The most important shift is not syntax.

The important shift is design.

Concepts let you build a vocabulary for your generic code. Instead of repeating the same hidden assumptions across templates, you define them once, name them clearly, and use them consistently.

That makes generic C++ code less mysterious, less fragile, and much easier to understand.