Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 SWAPPED
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
readWeak(n, a) with the arguments SWAPPED: compiles, 0 warnings
is_same<AddrAlias, LenAlias> = 1  -- they are the same type

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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) {}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
readStrong(A, N) correct order: compiles
is_same<Addr, Len> = 0  -- genuinely different types

Five 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 argument

Note 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
__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}; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rawOffset      instr=2    add w0, w0, w1, lsl #2|ret
strongOffset   instr=2    add w0, w0, w1, lsl #2|ret
byte-identical bodies: YES

Two 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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>) 24

Same 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Addr a{0x1000};      // explicit: the conversion is deliberate
Addr b = 0x1000;     // ERROR
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::uint32_t raw = a;      // ERROR
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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_{};
};
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-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 Cycles that can be divided by Cycles to yield a dimensionless ratio, but not by a scalar to yield Cycles unless you want that;
  • an Addr that supports + Len (an address plus a length is an address) and not + Addr;
  • a Len that supports Addr - Addr as its result, which is how std::ptrdiff_t relates 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_taliasstrong type
distinct typenono (measured is_same = 1)yes (measured 0)
swapped arguments caughtnono (measured, 0 warnings)yes (measured 1 error, 7 lines)
implicit conversion inyesyesno (measured 1 error)
implicit conversion out——no (measured 1 error)
unwanted arithmeticall of itall of itonly what you permit (measured)
sizeof / alignof4 / 44 / 44 / 4 (measured)
generated codebaselineidenticalbyte-identical (measured, same md5)
trivially copyableyesyesyes (measured 1)
usable in constexpryesyesyes (measured via static_assert)
readable in diagnostics—'Addr' (aka 'unsigned int')Id<AddrTag>, no expansion needed
costnonenoneboilerplate + .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 constexpr so the type composes with Chapter 21.2's compile-time work.
  • Define only the operations the meaning permits. Measured: Cycles * Cycles is 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 a std::hash specialisation before using it as a map key.
  • Add a stream inserter for logging, so printf noise 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> and Id<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 aka expansion needed.
  • That the generated code is byte-identical — two instructions, same md5 — with the same sizeof and alignof.
  • That the type is trivially copyable and standard layout, so it works at a bytewise boundary where std::tuple does not.
  • That explicit is 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 * Cycles not 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> and Id<AddrTag> directly, with no aka expansion, because the types are genuinely distinct.
  • Measured zero cost: rawOffset and strongOffset both compile to add w0, w0, w1, lsl #2 | ret — two instructions, identical md5 — with the same sizeof and alignof.
  • Measured trivially copyable and standard layout, so a strong type crosses a bytewise hardware boundary where Chapter 21.4 §3's std::tuple cannot.
  • explicit is load-bearing in both directions, and adding a conversion operator out undoes the technique.
  • constexpr members 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 * Cycles is 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.