Press enter or click to view image in full size
5000 is 5000 is 5000.
It might be milliseconds. It might be seconds. It might be the year, the price in cents, the height in pixels, or the number of bytes in your payload. TypeScript has no idea. It will happily let you pass any of them to any function that takes a number. That is not type safety. That is a vibe.
This is the bug you wrote last week:
setTimeout(retry, 5); // 5 milliseconds, not 5 seconds. Whoops.This is the bug your coworker wrote:
const expiresAt = Date.now() + ttl; // ttl is in seconds. Now you expire in 1970.This is the bug your finance system wrote:
chargeCustomer(amountInCents); // chargeCustomer expects dollars.Every “I just lost three hours” bug in this category has the same shape. Numbers carry meaning that TypeScript can’t see. Operators happily smush them together. And nothing complains until someone notices in production that the appointment reminders went out 1000 minutes late.
And we still write code this way. Every day. By choice.
What we do today
In our codebase, we never use a bare number to represent a quantity with a unit. Every quantity gets an opaque type tag:
type Milliseconds = Tagged<number, 'Milliseconds'>;
type Seconds = Tagged<number, 'Seconds'>;
type Minutes = Tagged<number, 'Minutes'>;
type Hours = Tagged<number, 'Hours'>;
type Days = Tagged<number, 'Days'>;
type Cents = Tagged<number, 'Cents'>;
type Percentage = Tagged<number, 'Percentage'>;
type Bytes = Tagged<number, 'Bytes'>;
type Pixels = Tagged<number, 'Pixels'>;Tagged<T, Name> is the well-known intersection-type trick. Grab Tagged from type-fest or roll your own; it's three lines either way. At runtime it's still just a number. At the type level, Milliseconds and Seconds are different things, and the compiler yells at you if you try to pass one where the other is expected.
So far so good. The trouble starts the moment you try to do math.
const ms = 5000 as Milliseconds;
const s = 30 as Seconds;
const total = ms + s; // total: number. No error. No warning. Nothing.That compiles. We just added milliseconds to seconds, got a meaningless number back, and the type system shrugged. The tags evaporate the moment you touch an operator, because TypeScript’s + is defined on number, not on whatever subtype you've cooked up. Now you've lost the unit and you can't even tell you mixed two of them up.
So you can’t write a + b. You have to write add(a, b). We maintain a small library of generic math wrappers that exist purely to preserve the type tag:
export const add = <T extends number>(a: T, b: T): T => (a + b) as T;
export const subtract = <T extends number>(a: T, b: T): T => (a - b) as T;
export const multiply = <T extends number>(a: T, b: number): T => (a * b) as T;
// ...and so on for divide, modulo, floor, ceil, round, abs, min, max, sumEvery arithmetic call site goes through one of these. A single wrapper is fine. Chain a few and it starts to grate:
const slot = add(start, multiply(period, index));In any other typed language, that’s start + period * index. We're writing nested function calls because the type system can't preserve a unit across an operator. This is absurd. We are writing Lisp inside TypeScript to compensate for the type system not knowing what a number is.
How do we make sure every call site actually goes through the wrappers? A custom ESLint rule, no-arithmetic-on-branded-primitives. It flags any +, -, *, /, or % between two opaque-typed operands and points you at the helper you should be using instead. Without it, someone eventually writes a + b and quietly hands a number to a function that wanted Milliseconds. With it, the codebase actually stays honest.
And then there’s the matter of literals. We wrote a second custom ESLint rule, no-branded-primitive-cast. It blocks as casts to opaque types when the value being cast is a computed expression. Literal casts are fine, because that's often the only way to give a literal an opaque type. Computed-value casts are where unsafe coercions sneak in, so those are what the rule catches.
// allowed: literal cast
const interval = 5000 as Milliseconds;// disallowed: casting a computed value
const interval = computeDelay() as Milliseconds; // ESLint error
This setup works. It’s caught real bugs at build time and in code review. Engineers can’t accidentally pass Seconds to a Milliseconds parameter, and refactors that change unit semantics are loud and obvious.
But look at what we had to build to get here:
- A custom
Tagged<>utility type. - A library of math wrappers covering every operator we use.
- Two custom ESLint rules we wrote from scratch, because nothing off-the-shelf knew what an opaque type was.
- A team convention that every unit goes through a conversion function.
That’s four custom pieces of infrastructure for the simple idea that a millisecond is not a second. Multiply that effort across every TypeScript shop that cares about correctness. We are collectively rebuilding the same workaround in parallel, inside thousands of codebases, because the language doesn’t ship the feature.
An aside on strings
Numbers aren’t the only place this pattern earns its keep. We use the same Tagged<> trick on strings too:
type UUID = Tagged<string, 'UUID'>;
type ISODate = Tagged<string, 'ISODate'>;
type EmailAddress = Tagged<string, 'EmailAddress'>;
type USPhoneNumber = Tagged<string, 'USPhoneNumber'>;
type UserID = Tagged<string, 'UserID'>;
type CustomerID = Tagged<string, 'CustomerID'>;
type OrderID = Tagged<string, 'OrderID'>;
type StripeCustomerID = Tagged<string, 'StripeCustomerID'>;
type StripePaymentID = Tagged<string, 'StripePaymentID'>;
type ShopifyOrderID = Tagged<string, 'ShopifyOrderID'>;Your database’s UserID and your CustomerID are both strings. So is your OrderID. So are an email, a session token, and the URL you're about to redirect someone to. Mixing any of them up is the kind of bug that quietly ships to production and then makes you cry six months later when a webhook hits the wrong customer record.
External service IDs are especially treacherous. A Stripe customer (cus_...), a Stripe payment intent (pi_...), and a Stripe charge (ch_...) all look distinct enough that you assume you'd spot the mistake in code review. The type system can't see those prefixes. It sees string, string, string. Pass a payment intent ID into a function that wanted a customer ID, and you find out at runtime when Stripe returns a 404. Or worse, when it doesn't.
Same story for your internal database IDs. A UserID, a CustomerID, and an OrderID are all bare strings as far as TypeScript is concerned. The day you accidentally pass a customer ID into a function that loads a user, you ship a hilarious bug or a quiet security hole, depending on what the function does next.
You rarely do arithmetic on a UUID, so the operator-overloading half of the story doesn’t apply here. But opaque types alone would make this entire class of bugs impossible to write. Half the language change, half the bugs.
The rest of this post sticks to numbers because that’s where the operator-overloading argument makes the case most cleanly. Strings are right behind, with the same problem and the same fix.
What we actually need
Two things.
One, a real way to declare opaque types. The intersection trick is a hack. There is no first-class language feature that says “this is number underneath, but the compiler should treat it as a distinct type that can't be implicitly assigned from number." Flow has had this since 2017:
opaque type Milliseconds = number;Two words. Done. That’s how short the language change is.
Two, a way for opaque types to survive arithmetic. The whole reason we maintain a math wrapper library is that +, -, *, / strip type information. If the type system understood that Milliseconds + Milliseconds = Milliseconds, the wrapper library would delete itself overnight.
const a = 1000 as Milliseconds;
const b = 2000 as Milliseconds;
const c = a + b; // Milliseconds
const d = a * 2; // MillisecondsThat’s the entire ask. No runtime emit. No type-driven codegen. No funny business. Just teach the type checker which operators on which opaque types preserve the tag. That’s it.
Two proposals that would help
There are two open TypeScript proposals that address this. Both worth knowing about, both worth supporting.
Proposal 1: opaque types (#202)
Open since 2014. Eleven years ago. The spiritual home of every “please give us opaque types” request, and several more specific proposals (Flow-style opaque types, abstract types, opaque type aliases) have been filed over the years and closed as duplicates of it.
This one focuses on the type identity:
opaque type Milliseconds extends number = number;
opaque type Seconds extends number = number;const ms = 5000 as Milliseconds;
const s = ms as Seconds; // Error: Seconds is not assignable from Milliseconds
That’s half the battle. Arithmetic would still strip the type, so we’d still need our wrapper library. But the type identity itself stops being a hack, and operator overloading can layer on later as a separate feature:
declare operator "+"(lhs: Milliseconds, rhs: Milliseconds): Milliseconds;
declare operator "*"(lhs: Milliseconds, rhs: number): Milliseconds;Proposal 2: primitive declarations with operators (#42218)
Open since 2021. More ambitious. Bundles the opaque type identity with declarations of how operators behave on it:
primitive Milliseconds extends number {
+(lhs: Milliseconds, rhs: Milliseconds): Milliseconds;
-(lhs: Milliseconds, rhs: Milliseconds): Milliseconds;
*(lhs: Milliseconds, rhs: number): Milliseconds;
/(lhs: Milliseconds, rhs: number): Milliseconds;
/(lhs: Milliseconds, rhs: Milliseconds): number; // ratio
%(lhs: Milliseconds, rhs: Milliseconds): Milliseconds;
}With that in place, the wrapper library disappears. Arithmetic just works:
const after = (start: Milliseconds, delta: Milliseconds): Milliseconds =>
start + delta;Both proposals are purely type-level. No runtime emit. No type-driven codegen. The output JavaScript is identical to today’s. Existing code that does plain number + number is unaffected. You opt in by declaring a primitive, and the new behavior only kicks in when those types are involved.
I prefer the layered approach: ship opaque types first because it is the smaller, easier feature to land, then add operator overloading as a separate proposal on top. Two small features tend to ship more often than one big one, and the layered version lets third-party libraries augment operators on types they don’t own. But honestly, either gets us to the right place. I will take either one.
F# already solved this. In 2010.
F# has had units of measure for fifteen years.
[<Measure>] type ms
[<Measure>] type slet timeout = 5000.0<ms>
let total = timeout + 200.0<ms> // float<ms>
let scaled = timeout * 2.0 // float<ms>
let bad = timeout + 5.0<s> // compile error
This is a solved problem in language design. The rest of us should be writing code as if our language already shipped the solution.
My pitch
If your codebase has functions that take number parameters representing units, and you haven't built some version of the setup I described above, you're shipping bugs you don't know about. Maybe today, maybe next quarter, maybe two years from now when someone wires up a new endpoint and confidently passes seconds where the API wanted milliseconds.
Don’t wait for the proposals to land. You can have most of this today, with the intersection-type trick, a small library of math wrappers, and a couple of lint rules. If you’re writing serious TypeScript and your numbers don’t have units and your IDs are bare strings, you are doing it wrong.
Pick an opaque type pattern. Tag your numbers with units. Tag your IDs. Wrap your math. Confine your casts to designated boundaries. The hour you spend setting this up pays for itself the first time it catches a unit-mismatch bug before it ships.
The proposals would make all of this dramatically smoother. #202 gives you opaque types directly in the language. #42218 goes further and lets the type system understand arithmetic on those types. If you have a take on which one should ship first, leave a comment with your real-world use case. Concrete examples from working codebases are the kind of signal that gets stuck proposals moving.
But the proposals aren’t a prerequisite. Start using units. Start tagging your strings. Stop shipping bugs that your type system could have caught.