How to stop $10,000 mistakes at compile time
How zero-cost type brands prevent unit conversion errors between your codebase and external SDKs
On September 23, 1999, NASA’s Mars Climate Orbiter entered Mars’ atmosphere on the wrong trajectory and disintegrated. The cause: Lockheed Martin’s ground software reported thruster impulse in pound-force seconds. NASA’s navigation system expected newton-seconds. The values differed by a factor of 4.45. Nobody caught the mismatch. A $327 million spacecraft burned up because two systems disagreed on what a number meant.
The root cause wasn’t a math error but a type error: two systems passed raw numbers with no indication of their units. TypeScript lets us make the same mistake. A number is a number, and the compiler can’t distinguish pounds from newtons, feet from meters, or cents from dollars.
const applyThrust = (impulse: number) => {
// expects newton-seconds
updateTrajectory(impulse);
};
// Lockheed's software returns pound-force-seconds
const thrusterImpulse: number = getThrusterData();
applyThrust(thrusterImpulse); // 4.45x error, spacecraft lost 😢
The problem with number
TypeScript’s type system is structural. Two values with the same shape are interchangeable. This works well for objects and interfaces. It fails for primitive types that carry semantic meaning.
Most of us aren’t building spacecraft navigation systems, but the same class of bugs shows up in everyday financial code. Consider a system that tracks amounts internally in minor currency units (cents) but integrates with a compliance SDK that expects major currency units (dollars). Both are number. TypeScript treats them as identical.
// Internal system: everything in cents
const transactionAmount = 10_000; // $100 in cents
// Compliance SDK expects dollars
sdk.checkTransaction({
settledAmount: transactionAmount, // Bug: sent $10,000 instead of $100
});
So how do we defend against this?
Flavored types: zero-cost type brands
A flavored type wraps a primitive with a compile-time tag that distinguishes it from other values of the same underlying type. The tag exists only in the type system and disappears at runtime.
declare const __flavor: unique symbol;
type Flavor<T, F> = T & { [__flavor]?: F };
Flavor intersects a base type T with an object containing an optional property keyed by a unique symbol. The unique symbol guarantees no other type can accidentally satisfy this shape1. The ? makes the property optional, so plain values of type T remain assignable without a cast2. This is what distinguishes flavored types from branded types, which force every value through a constructor or type assertion.
Applying this to financial SDKs
Let’s define flavor types for each unit:
type MinorCurrencyAmount = Flavor<number, "MinorCurrencyAmount">;
type MajorCurrencyAmount = Flavor<number, "MajorCurrencyAmount">;
Both are still number at runtime. But TypeScript now distinguishes them:
const processPayment = (amount: MinorCurrencyAmount) => {
/* ... */
};
const dollars = 100 as MajorCurrencyAmount;
processPayment(dollars);
// ~~~~~~~
// ❌ Argument of type 'MajorCurrencyAmount' is not assignable
// to parameter of type 'MinorCurrencyAmount'
Take a compliance screening API that checks transactions for anti-money-laundering (AML) violations that expects amounts in major currency while our system stores everything in minor currency. The boundary between these two systems is exactly where a handoff between developers might cause a critical error. Let’s defend against this with our newly-introduced flavored types.
Step 1: type our domain
We’ll define a type that includes currency for the most extensibility (i.e. don’t assume the system will only ever work in USD):
type MoneyMinor = {
amount: MinorCurrencyAmount;
currency: CurrencyIsoCode;
};
Every layer3 of our internal system passes MoneyMinor and agrees that amounts are in cents.
Step 2: type the SDK schema
At the SDK boundary, we’ll define the external contract with major currency4:
import { z } from "zod";
const transactionCheckSchema = z.object({
settledAmount: z.number().transform((v) => v as MajorCurrencyAmount),
settledCurrency: z.string(),
// ... other fields
});
The Zod schema validates the runtime shape and types the output. Any code consuming the parsed result gets MajorCurrencyAmount, not number.
Step 3: convert at a single chokepoint
We’ll handle mapping at the ResourceAccess layer that consumes the third-party Resource under-the-hood. The mapper layer sits between our internal types and the SDK types and is the only place where conversion happens:
import { minorToMajor } from "./currency";
// the output shape that the third-party Resource expects
type BaseRequest = {
settledAmount: MajorCurrencyAmount;
settledCurrency: CurrencyIsoCode;
};
const createBaseRequest = (
settled: MoneyMinor; // the input expected from our internal system, e.g. an Engine or Manager invoking this financial compliance ResourceAccesss
): BaseRequest => ({
settledAmount: minorToMajor(input.settled.amount, input.settled.currency),
settledCurrency: input.settled.currency,
});
minorToMajor accepts a MinorCurrencyAmount and returns a MajorCurrencyAmount. The function signature enforces the conversion direction5. Here’s what happens when someone tries to skip the conversion:
// returned from our internal ledger service
const settled: MoneyMinor = getLedgerEntry("tx_123").settled;
// ❌ Type 'MinorCurrencyAmount' is not assignable to type 'MajorCurrencyAmount'.ts
checkTransaction({
settledAmount: settled.amount,
settledCurrency: settled.currency,
});
// ✅ conversion handled internally
checkTransaction(createBaseRequest(settled));
The compiler blocks the direct assignment and forces us through createBaseRequest, where minorToMajor handles the conversion, so the bug is caught before it ever reaches code review.
Abstraction benefits
In a layered architecture, flavored types enforce contracts between layers without runtime overhead. The ResourceAccess layer is the single translation point, handling all mapping requirements from Managers and Engines that invoke it, allowing our system to be internally consistent while abstracting specific 3rd-party vendor requirements.
Preventing the Mars Orbiter bug
Now back to the $327 million spacecraft! The same two-line Flavor definition would have caught the bug:
type NewtonSeconds = Flavor<number, "NewtonSeconds">;
type PoundForceSeconds = Flavor<number, "PoundForceSeconds">;
const applyThrust = (impulse: NewtonSeconds) => {
updateTrajectory(impulse);
};
const thrusterImpulse = getThrusterData() as PoundForceSeconds;
applyThrust(thrusterImpulse);
// ~~~~~~~~~~~~~~~
// ❌ Argument of type 'PoundForceSeconds' is not assignable
// to parameter of type 'NewtonSeconds'
The compiler catches the mismatch. We’re forced through a conversion function:
const lbfSecondsToNewtonSeconds = (
impulse: PoundForceSeconds,
): NewtonSeconds => (impulse * 4.448_222) as NewtonSeconds;
applyThrust(lbfSecondsToNewtonSeconds(thrusterImpulse)); // ✅
Summary
A unit mismatch destroyed a $327 million spacecraft. In financial systems, a single missed minor-to-major conversion can mean sending 100x the intended amount. Flavored types eliminate this entire class of bug at zero runtime cost. The compiler catches unit mismatches before they reach QA, let alone production. For any organization handling financial transactions, scientific computations, or cross-system integrations, this is a direct reduction in operational risk.