A function asks for a timeout in milliseconds. You pass 5000. Later, another function asks for a timeout in seconds. You pass 5. Somewhere in between, you call setTimeout(duration, callback) and nothing happens for an hour and twenty-three minutes.
TypeScript does not save you here. 5000 and 5 are both number. The compiler sees no difference between a distance in meters and a distance in feet, a temperature in Celsius and a temperature in Fahrenheit, a timestamp and a duration. Your test suite probably does not catch it either, because the math is correct. The units are just wrong.
The fix is to stop treating units as documentation and start treating them as types.
Why number is the wrong type for physical quantities
TypeScript uses structural typing. Two objects are compatible if their shapes match. This is usually a feature, but for primitives like number it means all numbers are interchangeable. A number is a number is a `number.
Runtime checks can catch unit errors, but they are expensive to maintain and easy to skip. You would need to validate every function argument, every API response, every constant defined in another file. In practice, nobody does this. The checks become comments, and comments lie.
The alternative is to encode the unit directly into the type. At compile time, Seconds and Milliseconds become incompatible types. Multiply Meters by Meters and you get SquareMeters. Add Miles to Kilometers and the compiler refuses. At runtime, the value is still just a number. There is no wrapper object, no runtime validation, no performance cost. This is a zero-cost abstraction.
How phantom types turn a number into a branded unit
TypeScript does not support nominal typing for primitives, but it supports intersection types and unique symbols. You can brand a primitive so that two brands are incompatible even when the underlying value is identical.
Here is the pattern:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
The __brand property does not exist at runtime. It is a phantom type. It exists only in the type system. But it is enough to make Meters and Kilometers mutually incompatible.
You cannot accidentally assign a plain number to a branded type. This is the point. You must explicitly construct one, which forces you to state the unit.
A working unit system in TypeScript
Here is a minimal but complete implementation that handles construction, conversion, and arithmetic.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
Usage:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
The error appears where the bug is introduced, not where the value is eventually used. You do not need to trace velocity back through three files to discover that someone passed milliseconds to a seconds parameter.
Deriving compound units from base units
The pattern scales to compound units. Instead of hand-writing MetersPerSecond, you can derive it from base types using a generic constructor.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
In practice, you may not need full dimensional analysis. Most teams hit diminishing returns after a dozen or so unit types. The goal is not to model physics. The goal is to eliminate the most expensive category of bug: the one where the math works but the units do not.
The trade-offs you should know about
Branded types are not free. They cost ergonomics.
Every literal must be wrapped in a constructor. setTimeout(callback, 5000) becomes setTimeout(callback, milliseconds(5000)). That is more typing. If your team is inconsistent about constructors, you end up with unsafe casts scattered through the codebase. The pattern only works if everyone uses it.
Type inference also gets noisy. Array methods and generic functions can expose the brand in error messages. A plain number[] is easier to read than (number & { readonly __brand: "Milliseconds" })[]. You may need type aliases to keep signatures readable.
Serialization is another friction point. JSON has no concept of branded types. When you send a Meters value over the wire, it arrives as a plain number on the other side. You must reconstruct the brand at the boundary. This is the right place to do it, but it is extra code.
The biggest limitation is that this is a TypeScript-only technique. If your system includes Python services, Go microservices, or plain JavaScript consumers, the brands vanish at the language boundary. You still need runtime validation at system edges. Branded types protect internal TypeScript code. They do not replace schemas for external data.
How to introduce this without annoying your team
Do not brand every number in your codebase. Start with the parameters that have caused real incidents.
- Identify the last three unit-related bugs in production. Look for milliseconds vs seconds, currencies in different denominations, latitude vs longitude, or screen coordinates vs document coordinates.
- Brand those specific types. Add constructors and conversion functions.
- Update the functions where those values are used. Let the compiler guide you.
- Add a lint rule that forbids raw
numberfor those parameters.
Do not brand loop counters, array indices, or percentages. Those are dimensionless. Adding a brand there is ceremony without value.
If you are working in a language with stronger nominal typing, you have better options. Rust users should look at the uom crate. F# and OCaml have units of measure built into the compiler. TypeScript’s structural type system makes this a workaround, not a first-class feature. But the workaround is good enough to catch real bugs.
FAQ
Does this add any runtime overhead?
No. The brand is a compile-time-only construct. After compilation, meters(100) is just the number 100. There is no wrapper object, no extra property, no runtime check.
What about multiplication and division?
You need explicit functions or overloaded operators. TypeScript does not support operator overloading, so distance / time must go through a per() or speed() function. This is verbose, but it is also the reason the bug gets caught.
Can I use this with third-party libraries?
Only if the library accepts your branded type. If setTimeout expects number, you can pass Milliseconds because the brand is an intersection with number. The reverse is not true. If a library returns number, you must explicitly brand it before using it as a unit type.
How do I handle fractional units like HalfSeconds?
Use the base unit and a constructor. halfSeconds(1) returns Milliseconds(500). Do not create a brand for every subdivision. Keep the base unit count small.
Stop writing unit names in variable names
Calling a variable timeoutInMs is documentation. Documentation drifts. Calling it timeout: Milliseconds is a type. Types are enforced.
The next time you debug an issue and realize the timeout was off by a factor of a thousand, ask yourself whether the variable name was enough. It was not. Encode the unit in the type, let the compiler do the work, and stop trusting yourself to remember whether this particular function wants seconds or milliseconds.