NamingLANGUAGE-SPECIFICDOMAIN-SPECIFICCONTESTED

Units in Names and Types

A bare number carries no unit, so the unit lives in someone's head. Three rungs — comment, suffix, type — with escalating cost and escalating safety.

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.

The question

When is a unit a comment, when does it belong in the name, and when does it deserve its own type?

The requirement

A support ticket says a failed job retried immediately instead of after thirty seconds. The code says retryAfter = 30 in one place and setTimeout(5000) in another, and nobody can tell from reading which one was wrong.

The obvious build

Everyone on this team knows the timeout is in milliseconds. It is the platform convention, and adding Ms to every variable is noise.

Why it breaks

The convention holds until the first library that disagrees. setTimeout takes milliseconds, the retry config file is in seconds, the database column is in microseconds, and now "the platform convention" is three conventions.

How it breaks as requirements change
  • The convention holds until the first library that disagrees. setTimeout takes milliseconds, the retry config file is in seconds, the database column is in microseconds, and now "the platform convention" is three conventions.
  • It holds until a value crosses a boundary. A number leaving as JSON carries no unit at all, so the receiving service applies its own convention and the bug lands two systems away from the mistake.
  • It holds until someone does arithmetic. timeout + gracePeriod compiles perfectly whether or not the two agree, and there is no test that fails, only a behaviour that is quietly wrong by a factor of a thousand.
  • Percentages, ratios and basis points are worse than time, because the error factor is 100 rather than 1000 and therefore looks plausible in a log.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The language decides what "a type" costs: a Rust or Go newtype is free at runtime, a TypeScript branded type is free but erasable, a Python wrapper class is an allocation and an attribute lookup on every use.
  • Every wrapper type has to be unwrapped at the edges — JSON, SQL, environment variables, a third-party SDK — and each of those is a place the discipline can be broken.
  • The existing codebase has thousands of bare numbers. Any answer that requires converting all of them at once is not an answer.
Invariants
  • A value and its unit must travel together. Any point where they separate is a point where a future reader guesses.
  • Two quantities with different units must never be addable or comparable without an explicit conversion — that is the whole property being bought.
  • Money never becomes a floating-point number, at any rung. That is not a naming decision, it is a correctness one.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The type or the name owns the unit. A comment can explain a unit but cannot enforce one, so it is the weakest rung and should be treated as such.
  • The boundary adapter owns conversion: one place parses "30s" or a millisecond integer into the internal representation, and nothing inside converts again (Boundary Adapters).
  • The domain owns which unit is canonical internally. That choice should be made once, written down, and be the least surprising one for the domain — minor units for money, an explicit instant for time.
Boundaries
  • Inside a module, a typed quantity can flow freely and the compiler enforces the invariant. That is where the type pays.
  • At every serialisation boundary the type is erased and the unit becomes a contract term instead — which is why the API and schema must state the unit explicitly, no matter what the internal type does (Request Contracts: Required, Optional, Null and Absent).
  • The conversion functions are the boundary. If conversion happens in more than one place, the unit invariant has no owner and the type is decorative.

Three rungs, and what each one actually buys

These are not three styles to pick between by taste. They are a ladder where each rung costs more and defends against a strictly larger set of mistakes, and the engineering question is how far up a given quantity deserves to go.

The failure to notice is that rung two is nearly free and rung three is not. Most of the value in most codebases is in going from nothing to a suffix, and teams that debate wrapper types often have thousands of bare timeouts while they argue.

Escalating a quantity
  1. 1
    Bare number

    Nothing. The unit lives in the author's head and in whichever call sites happen to be nearby.

    fails by Any second convention entering the codebase, at which point every existing site becomes ambiguous at once.

  2. 2
    Comment

    Tells a reader who is looking at this line. Costs nothing and enforces nothing.

    fails by Being invisible at the call site, and going stale when the value moves (Documentation Decay).

  3. 3
    Unit in the name

    Puts the unit at every call site and in every signature, so mismatched arithmetic looks wrong to a human reader.

    fails by A tired human. timeoutMs + graceSeconds compiles and reviews fine at 6pm.

  4. 4
    Unit in the type

    Makes mismatched arithmetic a compile error. This is the only rung that survives fatigue.

    fails by Boundaries — every JSON, SQL and SDK edge unwraps it — and by an escape hatch that becomes the normal way to use it.

Nothing forces you to apply one rung uniformly. A payments codebase can reasonably run Money at rung three, durations at rung two and a page size at rung one, because the cost of being wrong differs by three orders of magnitude across those three.

What rung three looks like when it is worth it

The property being bought is narrow and specific: two quantities that must not be combined cannot be combined, and the compiler is the one enforcing it rather than a reviewer.

This only exists if the two types are genuinely distinct to the compiler. A type alias is not — it is a comment the compiler happens to read — and this catches teams out in TypeScript, Go and C constantly.

An alias is not a type; a brand is
1// Does nothing. Ms and Sec are both number.
2type Ms = number
3type Sec = number
4const wait: Ms = 30 as Sec // fine. That is the bug.
5
6// Distinct to the compiler, identical at runtime.
7type Ms2 = number & { readonly __unit: 'ms' }
8type Sec2 = number & { readonly __unit: 's' }
9
10const ms = (n: number) => n as Ms2
11const seconds = (n: number) => (n * 1000) as Ms2 // the one conversion
12
13function retryAfter(d: Ms2) { /* ... */ }
14retryAfter(seconds(30)) // ok
15retryAfter(30) // compile error — and this is the whole point

The conversion functions are the design. There is exactly one place that asserts "thirty of these are one of those", it is greppable, and it is the only thing that needs a test. Everything else is the compiler.

Choosing the rung

SIMPLIFIEDThe ladder omits a real fourth rung — dimensional analysis in the type system, as F# units of measure or Haskell's dimensional library provide — which composes over multiplication and division rather than only checking identity. It is left out because very few teams work in a language that offers it, and where it exists the reasoning above still selects when to use it.

The criteria are about the consequence of being wrong, not about how principled the codebase wants to feel. A loud failure that a test catches is a different problem from a plausible number that reconciles months later.

How far up the ladder does this quantity go?

How many units are in play, and what happens when they are confused?

Rung one — a local, single-unit value

when One convention exists, the value never leaves the function, and a mistake fails loudly and immediately.

cost Free, and it stops working the moment a second convention arrives. Accept that you will pay a search then.

Rung two — the unit in every name

when The value crosses a signature or a module. This is the default for almost everything.

cost Line width and a review habit. There is no serious argument against it, which is why it should be the floor rather than the debate.

Rung three — a dedicated type

when The quantity is central to the domain, several units genuinely coexist, and confusion produces a plausible wrong answer rather than a crash.

cost Conversion at every boundary, forever, plus vocabulary the whole team must learn and a migration in existing code (Value Objects).

Rung three plus arithmetic rules

when The domain has rules the unit alone does not capture — currency rounding, allocation across line items, period arithmetic across DST.

cost The type becomes a module with real behaviour and real tests. That is correct, and it is much more than a naming decision (Domain Modeling).

None of the above — fix the interface instead

when The ambiguity comes from a parameter list where any number can be passed to any slot.

cost A parameter object or named arguments removes the whole class of error more cheaply than a type per quantity (Introduce Parameter Object).

How to build it

Most important first.

  • Rung one — a comment. Cost: nothing. Buys: a reader who happens to look. Use it only for a local value with a short lifetime, never for anything crossing a function signature.
  • Rung two — the unit in the name: timeoutMs, weightKg, amountMinorUnits, expiresAtUtc. Cost: line width. Buys: every reader at every call site sees the unit, and mismatched arithmetic becomes visibly wrong to a human. This is the default and it should be near-universal.
  • Rung three — a type: Duration, Money, Percentage, Instant. Cost: a wrapper, conversion at edges, and vocabulary the whole team must learn. Buys: mismatched arithmetic stops compiling, which is the only rung a tired human cannot defeat.
  • Escalate to rung three when the quantity is domain-central (money in a payments system, duration in a scheduler), when several units genuinely coexist, or when a mistake is silent rather than loud (Value Objects).
  • Stay on rung two when there is exactly one unit in play, the value is configuration rather than a domain concept, or the language makes wrappers expensive or awkward enough that the team will route around them.
  • Whichever rung, name the boundary conversion explicitly — Duration.fromSeconds(config.retryAfterSeconds) — so the one place a unit is asserted is greppable.

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.

Cost of the next change
  • Adding a second currency under bare numbers costs an audit of every arithmetic site in the codebase, with no tool to enumerate them; under a Money type it costs adding a currency field and letting the compiler list the places that now fail to add.
  • Changing the internal canonical unit — seconds to milliseconds — costs a global search and human judgement at rung one or two, and a change to one conversion function plus a compile at rung three.
  • The next change after adopting the type is cheaper for arithmetic and more expensive at boundaries: every new integration adds one conversion you must write, and that cost is permanent and per-integration.
What the recommended approach costs
  • Wrapper types add vocabulary, indirection and unwrapping noise at every edge, and in a codebase where most values never leave a single function that noise buys very little.
  • Suffixed names are longer and can look redundant when the surrounding context makes the unit obvious — and reviewers who find them redundant stop asking for them.
  • Any of this is a migration in an existing codebase, and a half-finished migration is genuinely worse than either end state because now a bare number might mean either convention.

What can go wrong

Failure modes
  • The type exists and half the codebase uses raw numbers anyway, so the invariant holds nowhere and the wrapper is pure ceremony.
  • The type has a convenience escape hatch — .value, .raw, an implicit conversion — and it is used everywhere, which is the same failure with better branding.
  • The unit is in the type internally and absent from the API contract, so external callers still guess and the bug simply moves outside the repository.
  • Duration is introduced for time and money stays a float, because the migration was scoped by "what is easy" rather than by "what is silent when wrong".
Dependencies, and their direction
  • A quantity type becomes a dependency of every module that speaks about that quantity, which is a large deliberate fan-in and the reason it must stay tiny and stable (Stable Boundaries).
  • Serialisation depends on the type, in that direction: adding a field to Money is a change to every stored representation of it (Data Migration).
  • Third-party SDKs depend on nothing of yours, so every call into one is a conversion point you own.
Misreads
  • "So wrap every primitive." No. Primitive obsession is a real smell and so is wrapper obsession; the criterion is whether a mistake with this quantity is silent and whether more than one unit exists (Primitive Obsession).
  • "The type makes it safe." It makes it safe inside the process. At the JSON boundary the type is gone and the guarantee is whatever the contract says.
  • "Strong typing solves this." Only if the types differ. type Ms = number; type Sec = number in TypeScript are the same type and will silently interchange — branding, a newtype or a class is required for the property to exist.
  • "Names with units are Hungarian notation." Hungarian notation encodes the *type*, which the compiler already knows. A unit is domain information the compiler does not have, which is the opposite case.
Smells this explains
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Test the conversions at the boundary, including the round trip, because that is where the unit is actually asserted rather than assumed.
  • Property-test that arithmetic on the type preserves the unit and that conversion is lossless in the direction you claim it is — rounding on money is a domain rule, not an implementation detail (Property-Based Testing).
  • A contract test on the API that the wire field is the unit the documentation claims; the type inside the process cannot check that (Contract Tests).
  • Do not test the wrapper's getters. Test the thing the wrapper exists to prevent — that seconds and milliseconds cannot be combined.
How this design ages
  • Rung two ages well and quietly: names stay honest as long as reviewers care, and degrade gradually when they stop.
  • Rung three ages by accumulating methods. Money starts as an amount and a currency and eventually owns rounding, allocation across line items and formatting — which is fine, and is the point at which it deserves its own module.
  • The forcing function for rung three is almost always a second unit or a second currency arriving. Introduce it then; retrofitting is mechanical because the suffixes from rung two tell you what each site meant.

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-SPECIFICRung three is nearly free in Rust and Go, where a newtype has no runtime representation, and free-but-erasable in TypeScript, where a branded type disappears at the JSON boundary. In Python or Ruby it costs an object per value and a lot of .value at edges, which is why the same advice is standard practice in one community and considered over-engineering in another.
  • DOMAIN-SPECIFICIn payments, aviation, medical dosing and scheduling, a unit error is a correctness incident and rung three is cheap by comparison. In a CRUD application whose only quantity is a page size, rung two is the whole answer and rung three is ceremony.
  • CONTESTEDThe strongest case against wrapper types: a naming convention plus one lint rule catches the same class of bug at a fraction of the ceremony, and wrapper types create a permanent tax at every boundary where they must be unwrapped — SDKs, ORMs, serialisers, test fixtures — while giving no protection at exactly those points. Practitioners who have maintained a large Money type argue the unwrapping noise eventually exceeds the bugs prevented, and they have the maintenance history to point at.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Backendtimeouts
Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether a wrapper type costs an allocation, a pointer indirection or exactly nothing is a compilation and representation question, and it decides whether rung three is affordable in a hot loop.