Stop Passing Ints: How Tiny Types and C++20 Make Your APIs Safer (for Free)
Share this article
When int Is a Code Smell
Every engineer has seen it:
Date d(7, 4, 1979); // day, month, year? month, day, year? hope you remember
It compiles. It runs. It is one refactor, one tired engineer, or one localization tweak away from being wrong.
A short code example posted on Hacker News (source linked below) revisits a simple but powerful idea aligned with C++ Core Guidelines P.1: express ideas directly in code. Instead of treating calendar values as anonymous integers, the snippet introduces small, explicit types—Year, Month, Day—backed by a common CalendarType base and modern C++20 comparison support.
At first glance, it looks like ceremony. For high-risk, high-complexity systems, it looks like sanity.
If the type carries a concept, don’t ship it as an
intand hope for the best.
The Core Pattern: Tiny Types, Big Guarantees
The shared base type is minimal:
struct CalendarType {
unsigned int value;
bool operator==(const CalendarType& other) const = default;
std::strong_ordering operator<=>(const CalendarType& other) const = default;
};
Domain-specific wrappers build on it:
struct Year : CalendarType { explicit Year(int year) { value = year; } };
struct Month : CalendarType { explicit Month(int month) { value = month; } };
struct Day : CalendarType { explicit Day(int day) { value = day; } };
class Date {
public:
Date(Year year, Month month, Day day)
: m_year(year), m_month(month), m_day(day) {}
Year year() const { return m_year; }
Month month() const { return m_month; }
Day day() const { return m_day; }
private:
Year m_year;
Month m_month;
Day m_day;
};
Usage looks like this:
Date date1{Year(1970), Month(4), Day(7)};
Date date2{Year(1983), Month(1), Day(12)};
// Date date3{7, 4, 1979}; // does not compile: argument types don’t match
What this buys you:
- Clarity at call sites: You can read a constructor call like a sentence.
- Order safety: You cannot silently swap
DayandMonth. - Type-checked intent: The compiler enforces meaning, not just shape.
None of this is new in theory. What’s changed is the cost model.
“But Is It Free?”: Performance and Boilerplate in 2025 C++
Historically, the pushback against this style has come in two flavors:
- "It’s slower."
- "It’s too much boilerplate."
The example—and modern compilers—dismantle both.
1. Performance: No, Your Hot Path Won’t Notice
These wrappers are trivially small:
- They contain a single integer-like field.
- They are eligible for inlining, SROA (scalar replacement of aggregates), and other optimizations.
- In practice, optimized builds compile them down to the same code you’d get with raw
intparameters.
For any team that cares about correctness (finance, embedded, infrastructure, security), trading away type safety for hypothetical performance here is self-inflicted technical debt.
2. Boilerplate: C++20 Quietly Fixed the Worst Parts
Pre-C++20, the friction was real: hand-rolled comparison operators, repetitive constructors, and maintenance noise.
C++20's = default plus the three-way comparison operator (<=>) slashes that cost:
bool operator==(const CalendarType& other) const = default;
std::strong_ordering operator<=>(const CalendarType& other) const = default;
And with common behavior factored into CalendarType, specialized types like Year, Month, and Day become thin, readable wrappers.
The net result: the ergonomics gap between "lazy int" and "honest type" is now small enough that “we don’t have time” is mostly cultural, not technical.
Why This Matters Beyond Dates
It’s tempting to treat this as a toy example. It shouldn’t be.
This is exactly the class of bug that:
- slips through tests,
- survives code review,
- passes static analysis,
- and detonates in production.
Dates are only the polite example. Consider where you’re still using naked primitives today:
- Mixing
millisecondsandsecondsin timing code. - Swapping
user_idandtenant_idin multi-tenant systems. - Confusing
bytesvs.elementsin buffer math. - Passing
port,status_code, andtimeout_msas a row ofints.
These aren’t style nits; they’re the root cause of:
- subtle security vulnerabilities,
- data corruption,
- accounting mismatches,
- and outages that take hours to diagnose because "the types all match."
Tiny types turn semantic mismatches into compile errors.
Tradeoffs Worth Making (And a Few Worth Fixing)
The original snippet even annotates its own "costs" and "mitigations"—a useful mental model for teams considering similar patterns.
Key points for practitioners:
Cost: Readers must understand
Year,Month, andDaydefinitions.- Mitigation: That’s the point. The types encode your domain. A 10-second detour to a well-named struct beats spelunking a bug report three quarters later.
Cost: Slightly more code.
- Mitigation: Centralize common behavior (as with
CalendarType), lean on= default, and keep wrappers trivial.
- Mitigation: Centralize common behavior (as with
Cost: Interop friction with existing APIs expecting raw ints.
- Mitigation: Provide explicit, intentional accessors (
year.value, or anint value() const) at the boundary. Don’t leak primitives back into your core.
- Mitigation: Provide explicit, intentional accessors (
For organizations with mature coding standards, these are not theoretical concerns. They sit next to rules like "never use raw pointers for ownership" and "no unchecked std::string_view from temporary strings." Explicit tiny types should live in that same category.
From Cute Snippet to Cultural Norm
The HN example is small, repetitive, and almost aggressively un-clever—and that’s exactly why it matters.
It reflects a larger shift in C++ and systems engineering culture:
- From “hope the comments are right” to “make invalid states unrepresentable.”
- From "primitive obsession" to "types that match the problem space."
- From "performance excuses" to "let the optimizer earn its keep while we make the code honest."
If you’re leading a codebase that will still be alive five, ten years from now—an exchange, a database engine, a control system, a critical internal platform—the question isn’t whether you can afford to introduce domain-specific types.
It’s whether you can afford not to.
How to Start Applying This Tomorrow
- Identify 3–5 primitive-heavy areas: dates, money, identifiers, units, boundaries.
- Introduce focused wrappers with
explicitconstructors and defaulted comparisons. - Enforce usage at module boundaries and in new APIs; migrate internals opportunistically.
- Make it a standard: documenting these types is documenting your domain.
The path from chaotic int soup to self-explanatory, safer interfaces is incremental—and, with modern C++, remarkably cheap.
Sometimes better engineering isn’t a framework, or a new allocator, or a metaprogramming trick.
Sometimes it’s just refusing to pass three ints and call it a date.
Source: Original discussion and code snippet from Hacker News: https://news.ycombinator.com/item?id=45898278