Primitive Obsession
Money as number, email as string, a user id as int. Meaningful types move a class of mistake from runtime to compile time — and not every string needs a wrapper.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Which of these strings and numbers deserve a type of their own, and which are fine as they are?
Support a second currency. Every amount in the system is a number, and every one of them is in an unstated unit — some in cents, some in units, one in basis points.
They are numbers. Use number, name the variable amountCents, and be careful. Wrapping everything in classes is ceremony that makes the code harder to read for no behavioural gain.
The name lives on the variable, not on the value, so it is lost the moment the value is passed, returned, stored in an array or serialised. total(a, b) has no idea what it was handed.
- The name lives on the variable, not on the value, so it is lost the moment the value is passed, returned, stored in an array or serialised.
total(a, b)has no idea what it was handed. - Adding a second currency turns every arithmetic site into a question — what unit, what currency — and there is no compiler help in answering it, so the audit is manual and never provably complete.
- Argument-order mistakes are undetectable:
transfer(fromId, toId)andtransfer(toId, fromId)are both well-typed, and the bug is a production incident rather than a build failure (Long Parameter List). - "Be careful" does not scale past the people who were in the room. The convention is real knowledge that lives nowhere the compiler can see (Duplicate Knowledge).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The database columns are
numeric, and changing the schema is a separate, slower project (Data Migration). - The public JSON API sends amounts as numbers and cannot change without a version (Versioned Interfaces).
- The team writes TypeScript, where a wrapper type is either a runtime object with a cost or a compile-time brand with no runtime identity.
- An amount and its currency must never be separated; an amount without a currency is not a value, it is half of one.
- Two amounts in different currencies must never be added, and today the language will happily do it.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A
Moneytype owns the pairing of amount and currency and the rule that they cannot be separated or mixed. - Parsing owns the boundary: an untrusted string becomes an
Emailexactly once, at the edge, and everything downstream receives the validated type (Parse, Do Not Validate). - Nothing downstream is responsible for re-validating, which is the actual saving — the check moves from every function to one.
- The wrapper belongs at the boundary where untrusted becomes trusted. Inside that boundary the primitive should not be reachable (Trust Boundaries).
- Serialisation is a boundary too: the type exists in the domain, and the wire format stays a number or a string (Backward Compatibility as a Constraint).
- The boundary is not "every field in the system". Types earn their place where a mistake is plausible and expensive, not everywhere a primitive appears.
The smell, and the strings that are genuinely fine
The catalogue entry is easy to state and easy to over-apply, so the fine clause here is doing most of the work. The test is not "is this a primitive" but "are there two values of this primitive type in this codebase that must never be confused, and would confusing them be expensive?"
looks like string email, string currency, int cents, string userId and string orderId in the same signature, unit information carried in variable names, and validation of the same field repeated in five call sites.
suggests A concept in the domain has no representation in the code, so its rules are enforced by convention. Confusable values are interchangeable to the compiler, and the meaning of a value depends on where you found it.
fix Introduce a type for the values that are confusable or unit-bearing, construct it only through a fallible parser at the edge, and keep the primitive at the wire and the database. Leave the rest alone.
title, a description, a note — one meaning, no unit, no sibling to swap it with, no validation beyond non-empty. Wrapping these produces types that exist only to be unwrapped. It is also fine at genuine boundary code: a JSON parser, a CSV reader and a log formatter should traffic in primitives, because converting to domain types is precisely their job and doing it too early puts domain concepts in infrastructure (Boundary Adapters). And in a script with a one-week life, the convention is cheaper than the type and nothing will outlive it (When Design Does Not Pay).1type Cents = number & { readonly __unit: unique symbol }2type Eur = { amount: Cents; currency: 'EUR' }3 4// The compiler now refuses this:5// const total = eurAmount.amount + usdAmount.amount6 7// It does NOT refuse this, because JSON has no brands:8const m = JSON.parse(body) as Eur // unvalidated, now trusted9const m2 = Eur.parse(body) // fallible, and the only10 // constructor that should existThe last two lines are the design decision. A branded type without a parser gate is a comment the compiler happens to check in the places where the data never came from outside (Invariant Leaks).
What actually deserves a type
The decision below is the whole lesson compressed. Notice that two of the five branches end in "leave it as a primitive", which is roughly the right proportion for a real codebase.
Is there a mistake this type would prevent, and would that mistake be expensive?
when userId and orderId both strings; from and to both dates.
cost Wrap both. The compiler now catches argument-order errors that no test would have found. Cost is conversion at boundaries and noisier fixtures.
when Cents, milliseconds, metres, basis points.
cost Wrap. Unit confusion is the classic silent, expensive bug and it is exactly what a type prevents (Units in Names and Types).
when Email, IBAN, slug, country code.
cost Wrap, and make the parser the only constructor. The saving is removing the repeated check, not the wrapper itself (Parse, Do Not Validate).
when Title, description, free-text note.
cost Leave it. There is no mistake to prevent, and the wrapper adds vocabulary and conversions for nothing.
when A CSV importer, a migration script, a spike.
cost Leave it. Primitives are the correct currency at the edge, and a script that dies in a month cannot repay a type (When Design Does Not Pay).
What the wrapper costs, by language
__slots__ differ from ordinary objects. The column that matters and does not vary is the fourth: no language's type system enforces anything about data arriving from outside the program, so the parser is doing the work in every row.The reason this argument never settles is that its cost varies by an order of magnitude across languages, and most of the writing about it does not say which language it assumes. Below is what a single-field wrapper actually costs in each, which is usually enough to end the argument in a specific team.
| Language | Runtime cost | Enforced by | Survives a JSON boundary? | What that implies |
|---|---|---|---|---|
| Rust | None — newtype is erased | Compiler, exhaustively | Only via an explicit deserialiser you write | Wrap freely; the argument against is nearly empty |
| Kotlin / Swift | Usually none (value class) | Compiler | Needs an explicit converter | Wrap the confusable and unit-bearing values |
| Java / C# | An object, unless a record or struct | Compiler | Needs a converter or annotation | Wrap where it matters; watch allocation in hot loops |
| TypeScript | None — brands are erased | Compiler only, and only inside the program | No — as at the parse site defeats it entirely | Wrap, but the parser gate is the real design, not the brand |
| Python / Ruby | A real object with real cost | Tests and discipline | No | Wrap only the values with genuine behaviour, such as Money |
| Go | None — defined types are free | Compiler, though conversions are easy | Needs explicit marshalling | Cheap; the idiom is underused for ids and units |
How to build it
Most important first.
- Wrap where confusion is *possible and costly*: two values of the same primitive type that must never be swapped, or a value that carries a unit (Units in Names and Types).
- Make the type do work beyond wrapping.
Moneythat also refuses cross-currency addition earns its cost; a type that is a struct with one field and no rules mostly does not. - Construct only through a parser that can fail, so an instance of the type is itself the evidence that validation happened (Parse, Do Not Validate).
- Keep the primitive at the edges — database column, JSON field, log line — and convert once in each direction (Boundary Adapters).
- Use whatever the language makes cheap: a newtype in Rust, a value class in Kotlin, a branded alias in TypeScript, a small frozen class in Python. The design argument is the same; the cost is not.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a currency costs an audit of every arithmetic site in the codebase with no mechanical way to find them, plus a rounding review, plus the sites you missed — which surface as small monetary discrepancies that finance finds months later.
- Before: changing the unit — cents to minor units of a zero-decimal currency — is essentially unbounded, because nothing distinguishes an amount already in cents from one that is not.
- After: adding a currency costs a change inside
Moneyand a compile error at every site that assumed one currency, which is a complete, mechanical list. That list is the entire saving. - What is more expensive now: every boundary crossing needs an explicit conversion, so a new endpoint costs a mapping line it did not cost before, and a test fixture cannot just say
100.
- Every wrapper is friction at every boundary: serialisation, ORM, fixtures, logs, and the debugger view that used to show a number and now shows an object.
- In languages without zero-cost newtypes, wrapping in a hot path costs allocation, and in a tight loop that is measurable (Allocation and Copies).
- The guarantee is only as good as the constructor discipline. A team that adds an escape hatch "just for tests" has paid the whole cost for part of the benefit.
What can go wrong
- The type is introduced but the primitive constructor stays public, so half the codebase builds one without validating and the guarantee is not a guarantee.
- Wrapping is applied uniformly, producing forty single-field types nobody can keep straight, and the signal from the two that mattered is lost (Over-Decomposition).
- The wrapper is added in the domain and the persistence layer keeps its own float, so rounding differs between what is computed and what is stored.
- The mitigation fails: a branded type in TypeScript is erased at runtime, so anything crossing a JSON boundary is unvalidated again unless the parser is actually called — the compiler's guarantee stops exactly where the data comes from outside (Invariant Leaks).
- Everything in the domain now depends on the value type, which is fine because a value type depends on nothing.
- Serialisation, ORM mapping and test fixtures acquire a dependency on the conversion, and that conversion becomes a place bugs concentrate.
- "Wrap every primitive." That is the failure mode, not the lesson. A
UserNametype with no rules costs vocabulary and buys nothing; the criterion is whether a mistake is plausible and expensive (Premature Abstraction). - "A type replaces validation." It replaces *repeated* validation. Something still has to validate once, at the edge, and if that parser is weak the type is a lie that is now trusted everywhere (Invariant Leaks).
- "Strings are fine because we have tests." Tests check the paths you thought of. The point of the type is the paths you did not (Testing as Design Feedback).
- "Use floats for money and round at the end." This is the specific case where the primitive is not merely unclear but wrong; the representation cannot hold the values exactly and errors accumulate in a direction auditors notice.
- primitive-obsession
- long-parameter-list
- duplicate-knowledge
Testing it, and how it ages
- Test the parser at the boundary with the ugly inputs — empty, unicode, leading zeros, wrong currency codes — because that is now the single place validation happens (Parse, Do Not Validate).
- Property tests earn their keep here: for any two amounts in the same currency, addition is associative and never silently changes currency (Property-Based Testing).
- One round-trip test per boundary: domain value to wire and back is identity, including for the awkward cases like zero-decimal currencies.
- Value types tend to accumulate behaviour, which is usually good —
Moneygaining allocation and rounding rules keeps that knowledge in one place. - They go wrong when they start acquiring formatting and locale concerns, at which point presentation has moved into the domain and the type changes every time a designer does (Divergent Change).
- A wrapper introduced early is cheap; introduced late it is a codebase-wide migration, which is why this is one of the few structures worth building before the second case appears (The Cost of Change).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- LANGUAGE-SPECIFICThe argument's strength tracks what a wrapper costs. In Rust a newtype is free at runtime and enforced at compile time; in Kotlin or Swift it is near-free; in TypeScript a brand is compile-time only and vanishes at any JSON boundary; in Python or Ruby a wrapper is a real object with real cost and no static enforcement, so the same design buys much less. Advice written for one of these columns is routinely quoted in another.
- DOMAIN-SPECIFICMoney, physical units, identifiers and time zones are where confusion is both likely and expensive, so the case is strong. For a
titleor adescription— one meaning, no unit, no confusable sibling — there is nothing for the type to prevent and the wrapper is pure ceremony. - CONTESTEDThe strongest opposing view: wrappers buy compile-time guarantees at the cost of pervasive conversion noise, and the noise is itself a defect source — every mapper is a place to get it wrong, and dynamic-language codebases with disciplined edge validation have shipped correct money handling for decades without a single value type. The counter is that edge discipline is a property of a team at a moment, and a type is a property of the code; but the noise is real and honest people weigh it differently.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — whether a newtype has representation cost, and how erasure interacts with serialisation, is a runtime question that decides how strong this argument is in a given language.