C++ Part I · Module 21 · Modern C++ Type and Utility Tools
Strong Types: Making Wrong Code Hard to Write
Chapter 21.1 measured the limitation that makes this chapter necessary: a type alias introduces a name and not a type, so three differently-named aliases for a thirty-two-bit integer were assigned to one another with no diagnostic at any warning level. An address, a length and an offset are three different kinds of quantity that happen to share a representation, and the compiler cannot tell them apart because nobody told it they were different. A strong type tells it. The technique is to wrap the representation in a class whose constructor is explicit, give it only the operations its meaning permits, and let the type system do what it is for. The frozen purpose calls this the cheapest defect prevention available in a typed language, and this chapter's job is to establish both halves of that claim by measurement rather than advocacy. The prevention half is four distinct compile errors — a swapped argument pair, an unintended implicit conversion in, an unintended conversion out, and an arithmetic operation that the quantity's meaning does not support — each reported in a handful of lines naming the right thing at the right place, where the alias version produced nothing at all. The cheapness half is the generated assembly, which comes out byte-identical to the raw type it wraps: the same two instructions, the same registers, the same md5.
Advanced19 min readC++Part IFundamentalsModern C++
Part I · Module 21 · Chapter 21.7 · Strong Types: Making Wrong Code Hard to Write
1. The Defect
using AddrAlias = std::uint32_t;
using LenAlias = std::uint32_t;
static void readWeak(AddrAlias, LenAlias) {}
AddrAlias a = 0x1000;
LenAlias n = 64;
readWeak(n, a); // ARGUMENTS SWAPPEDreadWeak(n, a) with the arguments SWAPPED: compiles, 0 warnings
is_same<AddrAlias, LenAlias> = 1 -- they are the same typeThe call is wrong, it compiles, and nothing says so. readWeak now reads 4,096 bytes starting at address 64, which on a real bus is either a fault or — worse — a successful read of entirely the wrong region.
This is Chapter 21.1 §6's measurement arriving at its consequence. The signature looks like it distinguishes the parameters, and it does not, because AddrAlias and LenAlias are the same type with two labels.
The frequency of this defect is not hypothetical. Any signature with two or more same-typed parameters is exposed, and the exposure is proportional to how often the parameters are adjacent and how plausible a swap looks. read(addr, len), copy(dst, src), substr(pos, count), setRange(first, last) — all of them.
2. The Fix
template <typename Tag>
class Id {
public:
using value_type = std::uint32_t;
constexpr Id() = default;
constexpr explicit Id(std::uint32_t v) : v_(v) {}
constexpr std::uint32_t get() const { return v_; }
constexpr bool operator==(const Id& o) const { return v_ == o.v_; }
constexpr bool operator<(const Id& o) const { return v_ < o.v_; }
private:
std::uint32_t v_{};
};
struct AddrTag; struct LenTag; // declared, never defined
using Addr = Id<AddrTag>;
using Len = Id<LenTag>;
static void readStrong(Addr, Len) {}readStrong(A, N) correct order: compiles
is_same<Addr, Len> = 0 -- genuinely different typesFive things in that class are doing work.
The tag parameter makes Id<AddrTag> and Id<LenTag> distinct types — Chapter 18.4 §3's rule that each instantiation is unrelated, used deliberately. The tags are declared and never defined, because they are never instantiated; they exist only to be different from each other.
explicit on the constructor is the load-bearing keyword. Without it, every uint32_t would convert silently and the type would enforce nothing — §5.
constexpr throughout means the wrapper composes with Chapter 21.2's compile-time work: Addr values can be constants, template arguments and array bounds, which a wrapper that is not constexpr could not be.
get() is the only way out, which is §5's other half.
operator== and operator< are chosen rather than inherited. A strong type gets exactly the operations its meaning permits — §6.
The template-plus-tag form matters practically: it pays the boilerplate once for a whole family of types. Adding using FieldId = Id<FieldTag>; is one line.
3. The Four Errors
Each case is a separate build of one file, selected by a macro.
CASE ERRORS LINES FIRST ERROR
correct 0 1 —
-DSWAP 1 7 no matching function for call to 'readStrong'
-DIMPLICIT 1 13 no viable conversion from 'int' to 'Addr' (aka 'Id<AddrTag>')
-DMIXED 1 4 invalid operands to binary expression ('Cycles' and 'Cycles')
-DRAWMIX 1 4 no viable conversion from 'Addr' (aka 'Id<AddrTag>') to 'std::uint32_t'One error each, in four to thirteen lines. That is the quality that makes the technique usable — compare Chapter 20.2 §7's four errors across 138 lines from an unconstrained template, or Chapter 21.6 §4's 147 lines from a missing visitor overload. A strong-type violation is reported at the call site, in a sentence.
The swap case is the one §1 was about, and its note is worth reading in full:
strongbad.cpp:27:5: error: no matching function for call to 'readStrong'
27 | readStrong(n, a); // arguments swapped
strongbad.cpp:13:13: note: candidate function not viable: no known conversion
from 'Id<LenTag>' to 'Id<AddrTag>' for 1st argumentNote what is absent: there is no aka expansion. Chapter 21.1 §10 measured that an alias is rendered as 'Addr' (aka 'unsigned int'), because there is one type with two names and the compiler shows both. Here there is nothing to expand — Id<LenTag> and Id<AddrTag> are the types, and the message names them directly. The alias improved a diagnostic for a mistake the compiler could already see; the strong type created a diagnostic where there had been none.
4. It Costs Nothing
The claim that a wrapper is free should be measured, not asserted.
__attribute__((noinline)) std::uint32_t rawOffset(std::uint32_t base, std::uint32_t idx)
{ return base + idx * 4u; }
__attribute__((noinline)) Addr strongOffset(Addr base, std::uint32_t idx)
{ return Addr{base.get() + idx * 4u}; }rawOffset instr=2 add w0, w0, w1, lsl #2|ret
strongOffset instr=2 add w0, w0, w1, lsl #2|ret
byte-identical bodies: YESTwo instructions each, the same two instructions, and the same md5 of the instruction bodies. Both marked noinline so the comparison is between the function bodies rather than between inlining decisions.
The reason is that every member is constexpr and implicitly inline, and the class has one member of the wrapped type. There is nothing for the optimiser to remove because there was never anything there: the wrapper exists in the type system and not in the generated code. This is Chapter 18.1 §5's zero-overhead measurement for templates, arriving at the same answer for the same reason.
The layout confirms it from the other side:
sizeof(std::uint32_t) = 4 sizeof(Addr) = 4
alignof(std::uint32_t)= 4 alignof(Addr)= 4
is_trivially_copyable<Addr> = 1
is_standard_layout<Addr> = 1
sizeof(std::vector<Addr>) = 24 == sizeof(vector<uint32_t>) 24Same size, same alignment, trivially copyable and standard layout — so unlike Chapter 21.4 §3's std::tuple, a strong type can be memcpy'd, written out bytewise, or laid into a buffer a hardware block reads. That is not incidental; for this curriculum's audience it is the difference between a technique that is usable at the hardware boundary and one that is not.
And it composes with compile-time work:
Cycles{100} + Cycles{250} = 350 (addition makes sense)
Cycles{100} * 3 = 300 (scaling by a scalar makes sense)Both computed by static_assert, so both happened at compile time.
5. explicit, In Both Directions
The constructor being explicit is what makes the type a barrier rather than a label.
Addr a{0x1000}; // explicit: the conversion is deliberate
Addr b = 0x1000; // ERROR-DIMPLICIT 1 error no viable conversion from 'int' to 'Addr' (aka 'Id<AddrTag>')Without explicit, readStrong(64, a) would compile by converting 64 to an Addr, and the type would be documentation again. This is Chapter 12.5's argument in its most consequential application.
The other direction matters equally, and is the half people forget:
std::uint32_t raw = a; // ERROR-DRAWMIX 1 error no viable conversion from 'Addr' to 'std::uint32_t'There is no implicit conversion out either, because the class has no conversion operator. Getting the value requires a.get(). If you add operator std::uint32_t() const, the type silently decays back into a uint32_t at every use and §1's defect returns — so the absence of that operator is a design decision, not an omission.
The cost is .get() at every boundary with code that wants the raw type, which §8 accounts for honestly.
6. Choose the Operations the Meaning Permits
A strong type is not only a different name; it is an opportunity to remove operations that do not make sense.
class Cycles {
public:
constexpr explicit Cycles(long long c) : c_(c) {}
constexpr long long count() const { return c_; }
constexpr Cycles operator+(Cycles o) const { return Cycles{c_ + o.c_}; }
constexpr Cycles operator-(Cycles o) const { return Cycles{c_ - o.c_}; }
constexpr Cycles operator*(long long k) const { return Cycles{c_ * k}; }
// deliberately NO operator* with another Cycles
private:
long long c_{};
};-DMIXED 1 error invalid operands to binary expression ('Cycles' and 'Cycles')Cycles plus cycles is cycles. Cycles times a scalar is cycles. Cycles times cycles is not a quantity this program has, so the operator does not exist and the expression does not compile — four lines of diagnostic, naming both operand types.
That is dimensional analysis, enforced by the type system, and it is the most valuable form of this technique. A raw long long supports every arithmetic operation whether or not it means anything; a strong type supports the ones you chose. The same reasoning gives:
- a
Cyclesthat can be divided byCyclesto yield a dimensionless ratio, but not by a scalar to yieldCyclesunless you want that; - an
Addrthat supports+ Len(an address plus a length is an address) and not+ Addr; - a
Lenthat supportsAddr - Addras its result, which is howstd::ptrdiff_trelates to pointers.
Chapter 15.1 §8's one test — does the operator mean for your type what it means for a built-in? — is the question to apply to each candidate; the point here is that choosing is the feature.
7. Where the Standard Library Already Does This
Three examples worth knowing, because they establish that this is a mainstream technique and not a curiosity:
std::chrono::duration. std::chrono::seconds and std::chrono::milliseconds are distinct types, so a function taking a timeout cannot be handed the wrong unit — and conversions that lose precision require an explicit duration_cast. This is §6's dimensional analysis done thoroughly.
std::byte. Distinct from unsigned char, with only the bitwise operators defined, so arithmetic on raw memory has to be deliberate.
std::string_view. Distinct from const char*, carrying a length, so a function cannot receive a pointer and guess.
Iterator types, in fact: Chapter 20.2 §2 measured that std::vector<int>::iterator and std::list<int>::iterator are different types with different capabilities, which is why std::sort can refuse one at compile time.
Every one of those exists for this chapter's reason, and the fact that the library pays the boilerplate for them is evidence about the value.
8. What It Costs, Honestly
Four costs. None is zero, and none is large.
Boilerplate. A constructor, a getter, and whichever operators you permit. The tag-template form in §2 pays this once for a family, which reduces it to one using line per type — but the first type costs the class.
.get() at the boundaries. Every interaction with code that wants the raw value needs it. In practice this clusters: the boundaries are few and the interior is clean.
printf and serialisation get noisier. std::printf("%u", a.get()) rather than a. A stream operator<< helps for logging.
It is only worth it where the defect lives. A function with one uint32_t parameter cannot have a swapped-argument bug. The value is concentrated in signatures with two or more same-typed parameters, which is where §1's defect is possible, and in quantities with units, which is where §6's applies.
The honest summary: adopt it for the quantities that recur across your interfaces — addresses, lengths, identifiers, durations, counts — and not for every integer in the program.
Two notes on the implementation. A std::vector<Addr> is the same size as a std::vector<uint32_t> (measured), so containers cost nothing. And the type needs operator==/operator< or a std::hash specialisation before it can be a map key — Chapter 19.6 §9's requirements apply, and forgetting the hash is the most common practical annoyance.
9. The Comparison
raw uint32_t | alias | strong type | |
|---|---|---|---|
| distinct type | no | no (measured is_same = 1) | yes (measured 0) |
| swapped arguments caught | no | no (measured, 0 warnings) | yes (measured 1 error, 7 lines) |
| implicit conversion in | yes | yes | no (measured 1 error) |
| implicit conversion out | — | — | no (measured 1 error) |
| unwanted arithmetic | all of it | all of it | only what you permit (measured) |
| sizeof / alignof | 4 / 4 | 4 / 4 | 4 / 4 (measured) |
| generated code | baseline | identical | byte-identical (measured, same md5) |
| trivially copyable | yes | yes | yes (measured 1) |
usable in constexpr | yes | yes | yes (measured via static_assert) |
| readable in diagnostics | — | 'Addr' (aka 'unsigned int') | Id<AddrTag>, no expansion needed |
| cost | none | none | boilerplate + .get() |
10. What to Actually Do
- Wrap quantities that share a representation but not a meaning — addresses, lengths, identifiers, durations, counts.
- Use the tag-template form so the boilerplate is paid once for a family.
- Make the constructor
explicit. Measured: without it the type enforces nothing. - Do not add a conversion operator out.
.get()is the point. - Make everything
constexprso the type composes with Chapter 21.2's compile-time work. - Define only the operations the meaning permits. Measured:
Cycles * Cyclesis 1 error in 4 lines. - Spend it on signatures with two or more same-typed parameters — that is where the defect is possible.
static_assert(std::is_trivially_copyable_v<T>)if the type will cross a bytewise boundary.- Add
operator==,operator<and astd::hashspecialisation before using it as a map key. - Add a stream inserter for logging, so
printfnoise does not drive you back to raw types. - Do not wrap every integer. A single-parameter function has no swap to prevent.
11. Mistakes Worth Making Once
Using an alias and believing it protects you. Measured: the swapped call compiles with 0 warnings.
Forgetting explicit. Every uint32_t converts silently and the type becomes a comment.
Adding operator std::uint32_t() to avoid typing .get(). The type decays at every use and §1's defect returns.
Omitting constexpr. The wrapper then cannot be a template argument, an array bound or a static_assert subject.
Inheriting all the arithmetic from the wrapped type, which throws away §6's dimensional analysis.
Using it as a std::map key without operator<, or an unordered_map key without a std::hash — Chapter 19.6 §9.
Wrapping every integer in the program. The boilerplate becomes the dominant cost and the signal is lost.
Assuming the wrapper costs something. Measured byte-identical, same md5.
12. Interview Relevance
The frozen curriculum marks this chapter High, and it is a strong design question because the answer reveals whether someone has shipped the technique or only read about it.
"How would you stop an address being passed where a length was expected?" A distinct type per meaning, with an explicit constructor and no conversion out. The strong answer leads with the measurement that an alias does not do this.
"What does it cost at run time?" Nothing — and saying you have checked the assembly, two identical instructions with the same md5, is much better than asserting it.
"Why explicit?" Without it every underlying value converts silently and the type enforces nothing. Both directions matter: no conversion in, and no conversion out.
"How do you avoid writing the class once per type?" A tag template — Id<AddrTag>, Id<LenTag> — which uses the distinct-instantiation rule deliberately.
"Where does the standard library do this?" std::chrono durations, std::byte, std::string_view, and iterator categories.
"Where is it not worth it?" A function with one same-typed parameter has no swap to prevent; the value is in signatures with two or more, and in quantities with units.
"Can it cross a hardware boundary?" Yes — measured trivially copyable and standard layout, unlike std::tuple.
13. What You Should Be Able to Say Now
- That an alias does not prevent the swap — measured, 0 warnings — and a strong type does.
- That the tag template makes
Id<AddrTag>andId<LenTag>distinct by Chapter 18.4 §3's rule. - The four measured errors and their sizes: swap (7 lines), implicit in (13), wrong arithmetic (4), implicit out (4).
- That the diagnostic names both types directly, with no
akaexpansion needed. - That the generated code is byte-identical — two instructions, same md5 — with the same
sizeofandalignof. - That the type is trivially copyable and standard layout, so it works at a bytewise boundary where
std::tupledoes not. - That
explicitis load-bearing in both directions, and that adding a conversion operator out undoes the whole technique. - That choosing the permitted operations is dimensional analysis, and
Cycles * Cyclesnot compiling is the feature. - Where to spend it: signatures with two or more same-typed parameters, and quantities with units.
14. Exercises
Exercise 1 — the defect. Reproduce §1 with aliases and confirm the swap compiles at -Weverything. Then convert to strong types and read all four errors.
Exercise 2 — the assembly. Reproduce §4. Then remove constexpr from the getter, re-measure, and report whether the codegen changed.
Exercise 3 — the conversion out. Add operator std::uint32_t() const to Id and find how many of §3's four errors disappear.
Exercise 4 — dimensional analysis. Build Cycles, Bytes and Hertz with only the operations that make sense, including Bytes / Cycles yielding a throughput type. Write five expressions that must not compile and confirm each.
Exercise 5 — the family. Define five strong types with the tag template and count the lines. Compare with writing five separate classes.
Exercise 6 — as a key. Use Addr as a std::map key and then as an unordered_map key. Record what each requires and the error when it is missing.
Exercise 7 — the boundary. Write a function that fills a hardware descriptor struct containing strong types and memcpys it to a buffer. Assert trivial copyability, and explain why the same code with std::tuple members would be wrong.
Exercise 8 — where to spend it. Grep your own headers for functions with two or more same-typed parameters. Rank them by how plausible a swap is, and convert the top three.
15. Summary
- An alias names; a strong type distinguishes. Measured:
is_same<AddrAlias, LenAlias>= 1 and the swapped call compiled with 0 warnings;is_same<Addr, Len>= 0 and the same call is an error. - Measured, four distinct errors, one each: the swap (7 lines), an implicit conversion in (13),
Cycles * Cycles(4), an implicit conversion out (4) — all reported at the call site in a sentence. - The diagnostic names
Id<LenTag>andId<AddrTag>directly, with noakaexpansion, because the types are genuinely distinct. - Measured zero cost:
rawOffsetandstrongOffsetboth compile toadd w0, w0, w1, lsl #2 | ret— two instructions, identical md5 — with the samesizeofandalignof. - Measured trivially copyable and standard layout, so a strong type crosses a bytewise hardware boundary where Chapter 21.4 §3's
std::tuplecannot. explicitis load-bearing in both directions, and adding a conversion operator out undoes the technique.constexprmembers let it compose with Chapter 21.2 — measured,Cycles{100} + Cycles{250}asserted at compile time.- Choosing the permitted operations is dimensional analysis, and the refusal of
Cycles * Cyclesis the feature rather than a gap. - The tag template pays the boilerplate once for a whole family.
- Spend it where the defect is possible: signatures with two or more same-typed parameters, and quantities with units — not on every integer.
The last six chapters have each given a type a way to say more about itself. The final chapter of the module turns that outward: not what a type says about itself, but what a template says about the types it will accept. Chapter 21.8 is concepts, and it closes a loop opened two modules ago — Chapter 20.2 measured an unconstrained std::sort on a std::list producing four errors across a hundred and thirty-eight diagnostic lines, the first reported inside a library header, naming no line of the programmer's own code except the call. The chapter measures what the same mistake costs with the requirement stated in the signature, and the figure is sixteen lines.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
