Flavored types catch financial bugs at compile time

How zero-cost type brands prevent unit conversion errors between your codebase and external SDKs

A cent and a dollar are both number. TypeScript does not care which one you meant.

const chargeCustomer = (amount: number, fee: number) => {
  return sendPayment(amount + fee);
};

Is amount in cents or dollars? Is fee in the same unit? The compiler cannot tell. It sees number + number and moves on.

This is how a user wires $10,000 when they meant to send $100. One missed conversion and your system is off by 100x!

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 spectacularly for primitive types that carry semantic meaning.

Consider a banking 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 = 10000; // $100.00 in cents

// Compliance SDK expects dollars
sdk.checkTransaction({
  settledAmount: transactionAmount, // Bug: sent $10,000 instead of $100
});

The compiler accepts this without complaint. A 100x multiplier ships to production.

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. It 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 shape. The ? makes the property optional, so plain values of type T remain assignable. This is the “flavored” approach as opposed to a strict “branded” approach where the property is required and you must use constructor functions.

Now define distinct 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'

The compiler catches the unit mismatch before the code ever runs.

Applying this at SDK boundaries

Take a compliance screening API that checks transactions for anti-money-laundering (AML) violations. The external API expects amounts in major currency. Your system stores everything in minor currency. The boundary between these two systems is exactly where a handoff between developers might cause a critical error.

Step 1: type your domain

Define an internal money type that includes currency for the most extensibility:

type MoneyMinor = {
  amount: MinorCurrencyAmount;
  currency: CurrencyIsoCode;
};

Every layer of your internal system passes MoneyMinor around. The manager, engine, and resource access layers (see layered architecture with Effect) all agree that amounts are in cents.

Step 2: type the SDK schema

At the SDK boundary, define the external contract with major currency:

import { z } from "zod";

const transactionCheckSchema = z.object({
  settledAmount: z
    .number()
    .transform((v) => v as MajorCurrencyAmount)
    .optional(),
  settledCurrency: z.string().optional(),
  // ... other fields
});

The Zod schema validates the runtime shape and brands the output. Any code consuming the parsed result gets MajorCurrencyAmount, not number.

Step 3: convert at a single chokepoint

The mapper layer sits between your internal types and the SDK types. This is the only place where conversion happens:

import { minorToMajor } from "./currency";

const createBaseRequest = (input: {
  settled: MoneyMinor;
  initiated?: MoneyMinor;
}) => ({
  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 direction. You cannot accidentally pass a major amount in and double-convert it.

Here is what happens when someone tries to skip the conversion. Suppose your banking provider returns minor currency and your compliance SDK expects major currency:

type BankingLedgerOutput = {
  amount: MinorCurrencyAmount;
};

type ComplianceScreeningInput = {
  amount: MajorCurrencyAmount;
};

const screenTransaction = (input: ComplianceScreeningInput) => {
  // AML screening SDK call
};

const mapLedgerToScreening = (input: BankingLedgerOutput) => {
  // ❌ Type '"MinorCurrencyAmount"' is not assignable to type '"MajorCurrencyAmount"'.ts(2345)
  screenTransaction(input);

  const converted = minorToMajor(input.amount, "USD");
  screenTransaction({ amount: converted }); // ✅
};

The compiler blocks the direct passthrough and forces the explicit conversion. The bug never reaches code review.

Abstraction benefits

In a layered architecture, flavored types enforce contracts between layers without runtime overhead.

Each layer has a clear contract. The ResourceAccess layer is the single translation point, and the type system proves it.

If we had to add an additional external SDK that expects a different representation, say, a string of cents, we could define yet another flavored type for it. The compiler guarantees each SDK gets exactly the format it expects.

Misc

Flavored vs branded. Flavored types (optional tag) allow plain number to be assigned without a cast. Branded types (required tag) force every value through a constructor. For internal APIs where you control all the call sites, flavored types strike the right balance. For public APIs where you cannot trust callers, consider branded types with validation.

Zod transforms at boundaries. Pair Zod schemas with .transform() to brand values at parse time. This ensures that any data entering your system through an external API is correctly tagged the moment it arrives.

Summary

Currency conversion bugs in financial systems are expensive. A single missed minor-to-major conversion can mean sending 100x the intended amount. Flavored types eliminate this class of bug entirely at zero runtime cost. The compiler catches unit mismatches before they reach QA, let alone production. For any organization handling financial transactions across multiple integration points, this is a direct reduction in operational risk and audit exposure.