DEV Community

Cover image for Phantom Types in TypeScript: Stop Mixing Kilograms and Pounds at Compile Time
Gabriel Anhaia
Gabriel Anhaia

Posted on

Phantom Types in TypeScript: Stop Mixing Kilograms and Pounds at Compile Time


A warehouse integration sends a parcel weight to a label printer. The shipping API accepts kilograms. The product catalog stores pounds, because the catalog was scraped from a US supplier ten years ago and nobody renamed the column. Somewhere in the middle, a backend engineer writes total = boxWeight + itemWeight and ships it. The label prints 2.4 kg for a parcel that actually weighs five and a half kilos. The carrier weighs it on intake, flags every package on the truck, and the support inbox starts filling up before lunch.

The story is older than computers. NASA's Mars Climate Orbiter was lost in 1999 because one piece of ground software output pound-force-seconds (lbf·s) while the spacecraft expected newton-seconds (N·s). Same number, different unit, the spacecraft burned up in the Martian atmosphere. The bug class hasn't gone anywhere. Every time a number crosses a system boundary stripped of its unit, it becomes ammunition for the next outage.

TypeScript can refuse to compile that addition. The check happens at the type checker, before the code ever runs. No tests, no runtime guards. The mechanism is phantom types, and once you have them, a Quantity<Kg> and a Quantity<Lb> are no longer addable, no matter how badly the call site wants them to be.

Branded Types, the One-Paragraph Refresher

Branded types (covered in detail in the sibling post) let you tag a primitive with a phantom property so the compiler treats two structurally identical types as nominally distinct. A UserId and an OrderId are both string underneath; a Brand<string, "UserId"> and a Brand<string, "OrderId"> are not. Mix them up at a call site and the compiler stops the build. Brands solve the id confusion family of bugs cleanly. They do less when the bug is unit confusion. The tag you want isn't a single string literal but a parameter that flows through generic functions. That is what phantom types are for.

Phantom Types: The Brand That Takes a Parameter

A phantom type is a type parameter that appears in a generic's signature without ever appearing in its runtime value. The compiler sees it; the JavaScript engine never does. Haskell and OCaml have used the pattern for decades; F# made it famous for units of measure with first-class support; Effect's Brand module and fp-ts's Newtype are TypeScript ports of the same idea.

The shape is one extra type parameter on the brand:

declare const __unit: unique symbol
type Quantity<U> = number & { readonly [__unit]: U }
Enter fullscreen mode Exit fullscreen mode

Quantity<U> is still a number at runtime. The U parameter is a phantom. It controls type identity without contributing a single byte to the JavaScript output. Two Quantity values with different U parameters are different types, even though both compile to plain numbers.

That is the whole trick.

Worked Example 1: Units of Measure

Define the unit tags as empty interfaces, declare a Quantity<U>, and write smart constructors for each unit you care about.

interface Kg { readonly __unit: "Kg" }
interface Lb { readonly __unit: "Lb" }
interface Gram { readonly __unit: "Gram" }

declare const __q: unique symbol
type Quantity<U> = number & { readonly [__q]: U }

const kg = (n: number): Quantity<Kg> => n as Quantity<Kg>
const lb = (n: number): Quantity<Lb> => n as Quantity<Lb>
const g = (n: number): Quantity<Gram> => n as Quantity<Gram>
Enter fullscreen mode Exit fullscreen mode

Now write addition that only accepts two values of the same unit:

function add<U>(a: Quantity<U>, b: Quantity<U>): Quantity<U> {
  return (a + b) as Quantity<U>
}

const a = kg(2)
const b = kg(3)
const c = lb(4)

add(a, b) // Quantity<Kg>, value 5
add(a, c) // Argument of type 'Quantity<Lb>' is not
          // assignable to parameter of type 'Quantity<Kg>'.
Enter fullscreen mode Exit fullscreen mode

The second call does not compile. It cannot compile. The function signature constrains both arguments to share the same U, and Kg is not Lb. The Mars Climate Orbiter bug, expressed in five lines of TypeScript, refuses to type-check.

Conversion is the only way across unit boundaries, and it is explicit:

const kgToLb = (k: Quantity<Kg>): Quantity<Lb> =>
  lb(k * 2.20462)

const lbToKg = (p: Quantity<Lb>): Quantity<Kg> =>
  kg(p / 2.20462)

const total = add(a, lbToKg(c)) // Quantity<Kg>, value 3.81
Enter fullscreen mode Exit fullscreen mode

The conversion function is the seam where the unit changes. It is the only place where a Quantity<Lb> becomes a Quantity<Kg>, and grep finds every such seam in the codebase. That is the property F# gets you with its native units; it is the property TypeScript gets you with eight lines of phantom-type plumbing.

You can take the pattern further with phantom-type arithmetic. The unit becomes a structural type built from base tags:

interface Meter { readonly __unit: "Meter" }
interface Second { readonly __unit: "Second" }
interface Div<N, D> { readonly __num: N; readonly __den: D }
interface Mul<A, B> { readonly __a: A; readonly __b: B }

type Velocity = Quantity<Div<Meter, Second>>
type Acceleration = Quantity<Div<Meter, Mul<Second, Second>>>
Enter fullscreen mode Exit fullscreen mode

That is the road to F#-style fully-checked dimensional analysis, and libraries like uom-ts push it all the way to compile-time multiplication and division of units. Most TypeScript codebases do not need it. The flat-tag version above catches the bug class that actually ships.

Worked Example 2: Money and Currency

Currency is the same shape as units, with one extra property that bites: you cannot convert between currencies without an exchange rate. The phantom type makes the rate a required argument, not an optional one.

interface USD { readonly __currency: "USD" }
interface EUR { readonly __currency: "EUR" }
interface JPY { readonly __currency: "JPY" }

declare const __m: unique symbol
type Money<C> = number & { readonly [__m]: C }

const usd = (n: number): Money<USD> => n as Money<USD>
const eur = (n: number): Money<EUR> => n as Money<EUR>
const jpy = (n: number): Money<JPY> => n as Money<JPY>

function plus<C>(a: Money<C>, b: Money<C>): Money<C> {
  return (a + b) as Money<C>
}

const subtotal = plus(usd(40), usd(10)) // Money<USD>
const broken = plus(usd(40), eur(10))
// Argument of type 'Money<EUR>' is not assignable
// to parameter of type 'Money<USD>'.
Enter fullscreen mode Exit fullscreen mode

A Money<USD> and a Money<EUR> cannot be added. The compiler refuses < between them. Storing both in the same total field requires a field type that allows both currencies, which is a different and more honest design. Any cross-currency operation has to go through an explicit converter:

type Rate<From, To> = number & {
  readonly __from: From
  readonly __to: To
}

const usdToEurRate = 0.92 as Rate<USD, EUR>

function convert<From, To>(
  amount: Money<From>,
  rate: Rate<From, To>,
): Money<To> {
  return (amount * rate) as Money<To>
}

const inEur = convert(usd(100), usdToEurRate) // Money<EUR>, ~92
Enter fullscreen mode Exit fullscreen mode

The Rate<From, To> type is itself phantom-tagged on both ends. A rate going the wrong direction does not type-check. A function that takes a Rate<USD, EUR> cannot be passed Rate<EUR, USD>. You call invert on it explicitly, and the helper flips the phantom parameters along with the number:

const invert = <F, T>(r: Rate<F, T>): Rate<T, F> =>
  (1 / r) as Rate<T, F>
Enter fullscreen mode Exit fullscreen mode

The places this catches bugs are not exotic. A pricing function that subtracts a discount in the wrong currency. A reconciliation job that sums up transactions across regions and silently treats yen as dollars. A subscription rollover that bills a European customer in dollars because the upgrade flow crossed a service boundary that lost the currency. Every one of those is a Money<C> away from being a compile error.

Worked Example 3: Microservice IDs That Cannot Mix

Microservices throw off ids the way a furnace throws off heat. A userId from the auth service. An orgId from the identity service. An orderId from commerce. An eventId from the audit log. Every one of them is a string. Every one of them gets passed through fifteen hops, half of them written by different teams. The bug class is passing the right shape to the wrong service, and it lands the same way the refund-with-swapped-args bug lands: green CI, red Monday morning.

Phantom types let you stamp ids with their service of origin and refuse to mix them.

declare const __id: unique symbol
type Id<Service, Kind> = string & {
  readonly [__id]: [Service, Kind]
}

interface AuthService { readonly __service: "auth" }
interface OrgService { readonly __service: "org" }
interface CommerceService { readonly __service: "commerce" }

interface UserKind { readonly __kind: "User" }
interface OrgKind { readonly __kind: "Org" }
interface OrderKind { readonly __kind: "Order" }

type UserId = Id<AuthService, UserKind>
type OrgId = Id<OrgService, OrgKind>
type OrderId = Id<CommerceService, OrderKind>

const UserId = (raw: string): UserId => raw as UserId
const OrgId = (raw: string): OrgId => raw as OrgId
const OrderId = (raw: string): OrderId => raw as OrderId
Enter fullscreen mode Exit fullscreen mode

The two-axis tag has two halves: service of origin, kind of entity. That is what gives you the matrix of distinctions you actually want. A UserId from the auth service and a UserId from a future shadow user service in the recommendations stack are different types. A function that takes an auth-issued UserId will refuse a recommendations-issued one even though both are string user ids. That is the property you want when the recommendations service has its own id space and a hash collision is the kind of incident that fills a postmortem.

The handler at the receiving service brands ids exactly once, at the boundary:

type User = { id: UserId; email: string }

async function loadUser(id: UserId): Promise<User> {
  // db lookup elided; the brand survives the round-trip
  return { id, email: "u@example.com" }
}

app.get("/users/:id", async (req, res) => {
  const id = UserId(req.params.id) // smart constructor, single seam
  const user = await loadUser(id)
  res.json(user)
})
Enter fullscreen mode Exit fullscreen mode

Inside the service, the brand follows the value. Cross-service calls re-brand at the next boundary. The mixing-bug ceases to exist as long as the boundary discipline holds.

Where Phantom Types Break Down

Phantom types are a compile-time fiction over a runtime that does not know they exist. The fiction holds inside your TypeScript code. It evaporates at every system edge.

JSON. JSON.parse returns any. Every Quantity<Kg>, Money<USD>, UserId<AuthService> you serialize comes back at the other end as a plain number or string. Casting the parsed blob back into a phantom-typed shape is an unchecked assertion the runtime never validates. The fix is a parser at the boundary. Zod, Effect Schema, ArkType, and Valibot all emit branded outputs, and the brand is the proof the parse ran.

Database hydration. ORMs return string and number typed by the ORM's code generator, not by your phantom-tagged domain. A users.id column comes back as string, not UserId<AuthService>. Same fix: a mapper layer that brands once on the way in and trusts the brand thereafter.

Deeply nested data. A Quantity<Kg> inside an array inside an object inside a response is still a Quantity<Kg> to the type checker, but the moment the runtime touches JSON.parse, the entire tree is ambient any until something validates it. The deeper the nesting, the more attractive a JSON.parse(...) as Order cast looks, and the more dangerous it is. The mitigation is the same parser-at-the-edge discipline, applied recursively. A schema that brands the inner total field as Money<USD> produces a fully-branded tree from the top down, with no as lying about the contents.

Third-party SDKs. Stripe's amounts are integers in the smallest unit of the currency. Twilio's prices are strings. Auth0's user ids are opaque strings. Whatever you import from node_modules will hand you raw primitives. Brand them at the adapter; never let raw third-party types leak into domain code.

The pattern is the same in every case: phantom types pay for themselves in the interior of your system, where most of the bugs live. The boundary work is the cost of admission: parsers, adapters, mappers. Treat the seam as load-bearing and the interior gets to be safe.

Cost vs Benefit

Phantom types are not free. The constructors sit on the call site. Conversions become explicit functions instead of inline arithmetic. The first time a teammate sees Quantity<Div<Meter, Second>> in a code review, you will spend a slack thread explaining it. Type errors get longer because the compiler now has more to say about what went wrong.

Where phantom types earn their keep:

  • Numeric quantities with units that flow through enough functions to lose their original variable name. Weight, distance, time, temperature, anything you would otherwise suffix _kg / _ms / _celsius and hope nobody renames.
  • Money in any system that handles more than one currency. The cross-currency arithmetic bug is too easy and too common; the cost of a Money<C> is a rounding error against the cost of a single mis-billed customer.
  • Cross-service identifiers in microservice architectures where the same id shape is issued by multiple services and is never safe to interchange.
  • Trust levels on otherwise-identical strings. RawHtml vs SafeHtml, UnvalidatedInput<T> vs Validated<T>, Encrypted<T> vs Plaintext<T>. The runtime is identical; the meaning is not.

Where phantom types are not worth it:

  • Local variables that never escape a function. If the unit lives and dies inside ten lines, the variable name is enough.
  • Display strings rendered straight to the user. The brand has nowhere to flow.
  • Throwaway scripts and prototypes. The cost of the boundary parsers eats the benefit when the program lives for an afternoon.

Rule of thumb: brand a value when it crosses a function boundary and could be confused with another value of the same shape. Otherwise leave it raw.

Forward Motion

Pick one. The most common high-payoff target in a typical backend is money: every fintech, every billing system, every checkout flow has a currency-confusion bug story sitting in its postmortems. Promote number to Money<C> for the function that touches the most currencies and watch the call sites light up with the conversions that were always supposed to be there. The next is units of measure, if your system speaks to physical hardware, scales, sensors, or anything with a _kg suffix on a column name. The last is microservice ids, if you have more than one service issuing ids of the same shape.

Open the pull request Monday. The Mars Climate Orbiter bug is twenty-six years old. The fix is finally a few lines of TypeScript away.


If this was useful

Phantom types live in the same chapter of the conversation as branded types, conditional types, and the rest of TypeScript's type-level toolkit. The TypeScript Type System is the deep-dive book in The TypeScript Library — the chapter on brands and phantom parameters sits next to the chapters on mapped types, template literal types, and the patterns that turn TypeScript's type system into a real domain-modeling language.

  • TypeScript Essentials — entry point if you are a working developer who wants to feel confident across Node, Bun, Deno, and the browser: Amazon
  • The TypeScript Type System — the deep-dive on generics, mapped and conditional types, infer, template literals, and brands: Amazon
  • Kotlin and Java to TypeScript — the bridge for JVM developers, variance, null safety, sealed-to-unions, coroutines-to-async: Amazon
  • PHP to TypeScript — the bridge for PHP 8+ developers, sync-to-async paradigm, generics, discriminated unions: Amazon
  • TypeScript in Production — tooling, build, monorepos, library authoring across runtimes, dual ESM/CJS, JSR: Amazon

If you are picking up the language, start with Essentials. If you came from JVM or PHP, start with the bridge that matches you and add The Type System once you want to push further. Production is the one anyone shipping TypeScript at work will end up reading.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)