Enhancing Type Safety with Strong Types
Have you ever asked yourself which unit a function return type has? Does sleep
use milliseconds or microseconds? Have you ever mixed up the order of your function parameters? If so, Strong Types are here to help you prevent this issue from happening again.
Strong Typing is a technique that allows one to differentiate between semantically distinct values of the same underlying type. The idea is to wrap the underlying type to prevent accidental mixing and ensure structured conversion between types (e.g., conversion between seconds and hours). The use of strong types provides some benefits:
- Self-Documenting Code: Strong types make code more expressive by reflecting the intention of the domain directly in the type names. They make your APIs clearer and more readable, which makes it easier for other developers to understand the codebase.
- Improved Type Safety: Distinct types for different entities prevent unintended conversion. Furthermore, type-related bugs can be detected at compile time, reducing the possibility of hard-to-spot run-time bugs and enhancing code reliability.
- Easier Maintenance: Strong types can make refactoring or extending the code base easier. If your system needs to handle a new kind of measurement unit or in some parts of the code, you need another scaling (e.g., nano vs. milli), the type system can handle the integration and conversion for you.
Basic Implementation
The easiest way to implement strong types is via tag types. A tag type is a type definition (mostly an empty struct
), which provides a tag that allows the type system to distinguish the same type in different ways. The idea is to combine the value type with the tag type to create a new unique type. We can use a template
for this:
1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename VALUE_T, typename TAG_T>
class strong_type {
public:
using value_type = VALUE_T;
explicit constexpr strong_type(const value_type& value) : m_value(value) {}
friend inline constexpr value_type underlying_value(const strong_type& value) {
return value.m_value;
}
private:
value_type m_value{};
};
The strong_type
class template now provides a generic interface for strong types. The free friend function underlying_value
allows fetching the underlying value if needed. If you want to use strong_type
, you only need to define a unique tag and create an alias for readablity1:
1
2
3
4
5
6
7
8
9
#include <cstddef>
namespace detail {
struct user_id_tag {};
struct product_id_tag {};
} // namespace detail
using user_id = strong_type<std::size_t, detail::user_id_tag>;
using product_id = strong_type<std::size_t, detail::product_id_tag>;
The tag makes the types unique and allows the type system to distinguish between them.
Conversion
The proposed implementation will be enough to avoid issues like mixing up parameters and allows better reasoning about the code base. In the past, I worked on a system where parts of the system provided data in fractions of nautical miles, and other components provided the data in meters or fractions of meters. Strong types will help to avoid mixing them up, but they can do more for you. Strong types allow you to implement a type conversion that will handle the translation between such types at compile time.
Define the Ratio
The std::chrono
already provides a strong type which allows something like this: std::duration
. The std::duration
class declaration looks like this:
1
2
template<typename VALUE_T, typename PERIOD_T = std::ratio<1>>
class duration;
This implementation combines the value type of duration with his (compile-time) rational fraction. We only need to add a template parameter for the rational fraction if we want to provide the same functionality for our generic strong type. The implementation would look like this:
1
2
3
4
#include <ratio>
template <typename VALUE_T, typename TAG_T, typename RATIO_T = std::ratio<1>>
class strong_type;
The default type for RATIO_T
allows us to use the type in the same way like before. If we want to define types that should enable a conversion, we need to use the same tag with the matching ratio2:
1
2
3
4
5
6
7
8
namespace detail {
struct distance_tag {};
}; // namespace detail
using meter = strong_type<double, detail::distance_tag, std::ratio<1>>;
using centimeter = strong_type<double, detail::distance_tag, std::centi>;
using millimeter = strong_type<double, detail::distance_tag, std::milli>;
using nautical_mile = strong_type<double, detail::distance_tag, std::ratio<1'852,1>>;
If you want to improve the readability of these aliases, you can define a similar type for the distance like the STL has done for std::duration
:
1
2
3
4
5
6
7
template<typename VALUE_T, typename RATIO_T>
using distance = strong_type<VALUE_T, detail::distance_tag, RATIO_T>;
using meter = distance<double, std::ratio<1>>;
using centimeter = distance<double, std::centi>;
using millimeter = distance<double, std::milli>;
using nautical_mile = distance<double, std::ratio<1'852,1>>;
Type Casting
Now we have defined the ratio between similar types. The next step would be to convert between the types. The STL has defined a dedicated cast for converting durations: std::duration_cast
. We can provide a similar cast for our distance unit. To do so, we need first some helpers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace detail {
template <typename>
struct ratio;
template <typename VALUE_T, typename TAG_T, typename RATIO_T>
struct ratio<strong_type<VALUE_T, TAG_T, RATIO_T>> {
using type = RATIO_T;
};
template <typename T>
using ratio_t = typename ratio<T>::type;
template <typename>
struct value;
template <typename VALUE_T, typename TAG_T, typename RATIO_T>
struct value<strong_type<VALUE_T, TAG_T, RATIO_T>> {
using type = VALUE_T;
};
template <typename T>
using value_t = typename value<T>::type;
} // namespace detail
These helpers will allow us to extract the ratio and value types for our distances. To define the conversion, we need first to “unify” the values. To do so, we need to define the ratio between our types and get the common type of the values. To get the ratio between the types, we can divide them with std::ratio_divide
:
1
using ratio_t = typename std::ratio_divide<IN_RATIO_T, OUT_RATIO_T>::type;
For the detection of the common type, we can use std::common_type
:
1
using common_t = typename std::common_type_t<IN_VALUE_T, OUT_VALUE_T, std::intmax_t>;
The last step would be to calculate the new value based on our common types:
1
2
const auto out_value = in_value * static_cast<common_t>(ratio_t::num) /
static_cast<common_t>(ratio_t::den);
And if we put everything together, it will look like this3:
1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename TARGET_DISTANCE_T, typename VALUE_T, typename RATIO_T>
constexpr auto distance_cast(
const distance<VALUE_T, RATIO_T>& distance) noexcept -> TARGET_DISTANCE_T {
using out_ratio_t = typename detail::ratio_t<TARGET_DISTANCE_T>::type;
using ratio_t =typename std::ratio_divide<RATIO_T, out_ratio_t>;
using out_value_t = typename detail::value_t<TARGET_DISTANCE_T>;
using common_t = typename std::common_type_t<VALUE_T, out_value_t, std::intmax_t>;
const auto in_value = underlying_value(distance);
const auto out_value = in_value * static_cast<common_t>(ratio_t::num) /
static_cast<common_t>(ratio_t::den);
return TARGET_DISTANCE_T{static_cast<out_value_t>(out_value)};
}
The usage of this would be similar to the use of std::duration_cast
:
1
2
3
constexpr nautical_mile nm(1);
constexpr auto m = distance_cast<meter>(nm);
static_assert(underlying_value(m) == 1'852);
Notes about Generalization
It is possible to generalize operations between strong types. So it would be easy to implement a generic cast for strong types or to add generic possibilities for operations like addition or comparison, but they are not helpful in every context. So it would have some use to add two distances with each other, but it could be an error if you allow the same with identifiers.
If we want to provide this functionality, the easiest way would be with traits over the tag type. This could look like the following4:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace detail {
template <typename>
struct addition_allowed : std::false_type {};
template <>
struct addition_allowed<distance_tag> : std::true_type {};
template <typename T>
inline constexpr auto addition_allowed_v = addition_allowed<T>::value;
} // namespace detail
template <typename VALUE_T, typename TAG_T, typename RATIO_T>
requires detail::addition_allowed_v<TAG_T>
constexpr auto operator+(const strong_type<VALUE_T, TAG_T, RATIO_T>& lhs,
const strong_type<VALUE_T, TAG_T, RATIO_T>& rhs)
-> strong_type<VALUE_T, TAG_T, RATIO_T> {
return strong_type<VALUE_T, TAG_T, RATIO_T>{underlying_value(lhs) +
underlying_value(rhs)};
}
Summary
Strong types are valuable to enhance type safety and make the code more expressive and self-documenting. By employing tag types and encapsulation, we can create distinct types based on the same underlying value type, preventing unintended conversions and improving code clarity. It also allows us to provide generalized operations between convertible types like for physical units.
Embracing strong types is a step toward writing cleaner, safer, and more maintainable C++ code, ultimately leading to a more enjoyable and productive development experience.5
References
Blogs
- Fluent {C++}: Strongly typed constructors
- Fluent {C++}: Strong types for strong interfaces
- Fluent {C++}: NamedType: The Easy Way to Use Strong Types in C++
- Fluent {C++}: Strong Units Conversions
Videos
- Richárd Szalay: CppNow 2023 Migration to Strong Types in C++: Interactive Tooling Support
Code
- Implementation of strong types by Jonathan Boccara: NamedType
- Strong types with customizing behavior: strong_type
Footnotes
compiler explorer link: https://godbolt.org/z/KKW1fdfvc ↩
compiler explorer link: https://godbolt.org/z/3rGdjWjfd ↩
compiler explorer link: https://godbolt.org/z/G71a18cn9 ↩
compiler explorer link: https://godbolt.org/z/MfhE9GsGz ↩
The use of strong types is also recommended by the C++ Core Guidelines ↩