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.

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.

Want more practical Modern C++ breakdowns like this?
I write a monthly newsletter, From complexity to essence in C++, where I explain Modern C++, CMake, testing, and real-world engineering trade-offs with clear examples.
Subscribe here: From complexity to essence in C++

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.

Want to go deeper?

If you want to see how Modern C++, CMake, testing, and project structure fit together in a real application, I cover that in my book:

No More Helloworlds — Build a Real C++ App

It shows how to move beyond isolated examples and build a real C++ project with clean architecture, modern tooling, and practical engineering decisions.

Learn more: No More Helloworlds — Build a Real C++ App