Last active
May 27, 2026 01:38
-
-
Save bluepnume/d58beb26bf069bd584e15b9a5cea7232 to your computer and use it in GitHub Desktop.
TypeScript needs units
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # TypeScript's number type is a lie | |
| `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: | |
| ```ts | |
| setTimeout(retry, 5); // 5 milliseconds, not 5 seconds. Whoops. | |
| ``` | |
| This is the bug your coworker wrote: | |
| ```ts | |
| const expiresAt = Date.now() + ttl; // ttl is in seconds. Now you expire in 1970. | |
| ``` | |
| This is the bug your finance system wrote: | |
| ```ts | |
| 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: | |
| ```ts | |
| 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](https://github.com/sindresorhus/type-fest#tagged) 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. | |
| ```ts | |
| const ms: Milliseconds = 5000; | |
| const s: Seconds = 30; | |
| 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: | |
| ```ts | |
| 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, sum | |
| ``` | |
| Every arithmetic call site goes through one of these. A single wrapper is fine. Chain a few and it starts to grate: | |
| ```ts | |
| 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. | |
| ```ts | |
| // 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 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: | |
| 1. A custom `Tagged<>` utility type. | |
| 2. A library of math wrappers covering every operator we use. | |
| 3. Two custom ESLint rules we wrote from scratch, because nothing off-the-shelf knew what an opaque type was. | |
| 4. 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: | |
| ```ts | |
| 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: | |
| ```ts | |
| 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. | |
| ```ts | |
| const a: Milliseconds = 1000; | |
| const b: Milliseconds = 2000; | |
| const c = a + b; // Milliseconds | |
| const d = a * 2; // Milliseconds | |
| ``` | |
| That'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](https://github.com/microsoft/TypeScript/issues/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: | |
| ```ts | |
| opaque type Milliseconds extends number = number; | |
| opaque type Seconds extends number = number; | |
| const ms: Milliseconds = 5000; | |
| const s: Seconds = ms; // 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: | |
| ```ts | |
| declare operator "+"(lhs: Milliseconds, rhs: Milliseconds): Milliseconds; | |
| declare operator "*"(lhs: Milliseconds, rhs: number): Milliseconds; | |
| ``` | |
| ### Proposal 2: primitive declarations with operators ([#42218](https://github.com/microsoft/TypeScript/issues/42218)) | |
| Open since 2021. More ambitious. Bundles the opaque type identity with declarations of how operators behave on it: | |
| ```ts | |
| 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: | |
| ```ts | |
| 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. | |
| ```fsharp | |
| [<Measure>] type ms | |
| [<Measure>] type s | |
| let 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 | |
| ``` | |
| Production. Compile-time only. Erased at codegen. Mixed units are type errors. The feasibility argument was settled before half the engineers using TypeScript today had written their first line of code. | |
| 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](https://github.com/microsoft/TypeScript/issues/202) gives you opaque types directly in the language. [#42218](https://github.com/microsoft/TypeScript/issues/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. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment