TypeScript's number type is a lie

bluepnume1 pts1 comments

TypeScript’s number type is a lie | by Daniel Brain | May, 2026 | MediumSitemapOpen in appSign up<br>Sign in

Medium Logo

Get app<br>Write

Search

Sign up<br>Sign in

TypeScript’s number type is a lie

Daniel Brain

8 min read·<br>Just now

Listen

Share

Press enter or click to view image in full size

5000 is 5000 is 5000.<br>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.<br>This is the bug you wrote last week:<br>setTimeout(retry, 5); // 5 milliseconds, not 5 seconds. Whoops.This is the bug your coworker wrote:<br>const expiresAt = Date.now() + ttl; // ttl is in seconds. Now you expire in 1970.This is the bug your finance system wrote:<br>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.<br>And we still write code this way. Every day. By choice.<br>What we do today<br>In our codebase, we never use a bare number to represent a quantity with a unit. Every quantity gets an opaque type tag:<br>type Milliseconds = Tagged;<br>type Seconds = Tagged;<br>type Minutes = Tagged;<br>type Hours = Tagged;<br>type Days = Tagged;<br>type Cents = Tagged;<br>type Percentage = Tagged;<br>type Bytes = Tagged;<br>type Pixels = Tagged;Tagged 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.<br>So far so good. The trouble starts the moment you try to do math.<br>const ms: Milliseconds = 5000;<br>const s: Seconds = 30;<br>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.<br>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:<br>export const add = (a: T, b: T): T => (a + b) as T;<br>export const subtract = (a: T, b: T): T => (a - b) as T;<br>export const multiply = (a: T, b: number): T => (a * b) as T;<br>// ...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:<br>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.<br>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.<br>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.<br>// allowed: literal cast<br>const interval = 5000 as Milliseconds;// disallowed: casting a computed value<br>const interval = computeDelay() as Milliseconds; // ESLint errorThis 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.<br>But look at what we had to build to get here:<br>A custom Tagged<> utility type.<br>A library of math wrappers covering every operator we use.<br>Two custom ESLint rules we wrote from scratch, because nothing off-the-shelf knew what an opaque type was.<br>A team convention that every unit goes through a conversion function.<br>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...

type number tagged milliseconds const typescript

Related Articles