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 int and 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 Day and Month.
  • 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:

  1. "It’s slower."
  2. "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 int parameters.

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 milliseconds and seconds in timing code.
  • Swapping user_id and tenant_id in multi-tenant systems.
  • Confusing bytes vs. elements in buffer math.
  • Passing port, status_code, and timeout_ms as a row of ints.

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, and Day definitions.

    • 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.
  • Cost: Interop friction with existing APIs expecting raw ints.

    • Mitigation: Provide explicit, intentional accessors (year.value, or an int value() const) at the boundary. Don’t leak primitives back into your core.

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 explicit constructors 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