Un Error de Tipo de 327 Millones de Dólares
En 2022, una importante plataforma fintech procesó una transferencia masiva de 32,700,000.00. El monto se almacenó como un number simple. Un servicio downstream asumió que estaba en centavos. No lo estaba.
El error sobrevivió a la revisión de código, las unit tests y las integration tests. El sistema de tipos vio number y number y dio por terminado el asunto. Dos tipos idénticos, perfectamente compatibles, catastróficamente incorrectos.
Este es el problema de las divisas en pocas palabras. Tu sistema de tipos evita sumar un string a un integer. No evita sumar yenes japoneses a dólares estadounidenses, ni tratar un monto en unidad principal como un monto en unidad menor. Son cantidades semánticamente diferentes, pero en TypeScript, Rust, Go y la mayoría de los lenguajes mainstream comparten un único tipo.
Por Qué los Tipos Numéricos Nativos Fallan con el Dinero
number, f64, int, BigDecimal. Todos codifican magnitud, no significado.
Un number puede representar 100 dólares, 100 centavos o 100 euros. El sistema de tipos trata los tres como idénticos. Puedes sumarlos, compararlos y pasarlos a cualquier función que espere un number sin una sola queja.
function processPayment(amount: number) {
// Is this dollars? Cents? Euros?
// The type system does not know, so it cannot help you.
}
const usd = 100 // 100 USD
const cents = 10000 // 10000 cents, also 100 USD
const eur = 100 // 100 EUR
processPayment(usd + eur) // Compiles. Wrong.
processPayment(usd + cents) // Compiles. Also wrong.
La defensa estándar son las convenciones de nomenclatura. Llamar a la variable amountInCents o amountUsd. Eso funciona hasta que alguien refactoriza, copia un valor a través de un boundary o simplemente lee mal el nombre. Los comentarios y las convenciones de nomenclatura no son enforceables.
Lo que necesitas es un tipo que codifique tanto el valor numérico como la unidad de divisa. El compiler debería rechazar USD + EUR de la misma manera que rechaza string + number.
Phantom Types: Enseñando al Compiler sobre Divisas
La técnica que funciona se llama phantom type. Defines un wrapper genérico alrededor de un number donde el parámetro de tipo porta la tag de divisa. El parámetro de tipo nunca aparece en runtime, pero el compiler lo usa para enforcear constraints.
Aquí hay una implementación completa y funcional en TypeScript:
// money.ts
declare const brand: unique symbol;
type Currency = "USD" | "EUR" | "JPY" | "GBP";
type Money<C extends Currency> = number & {
readonly [brand]: C;
};
function money<C extends Currency>(amount: number, _currency: C): Money<C> {
return amount as Money<C>;
}
function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
return (a + b) as Money<C>;
}
function subtract<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
return (a - b) as Money<C>;
}
El símbolo brand crea una distinción de tipo nominal. Dos valores Money con diferentes marcas de divisa son incompatibles, aunque ambos sean number por debajo.
El uso se ve así:
const price = money(100, "USD");
const tax = money(8.5, "USD");
const total = add(price, tax); // Money<"USD">, OK
const invoice = money(500, "EUR");
const wrong = add(price, invoice);
// Error: Argument of type 'Money<"EUR">' is not assignable
// to parameter of type 'Money<"USD">'.
El compiler ahora entiende que USD y EUR son cosas diferentes. No puedes sumarlos por accidente. No puedes pasar EUR a una función que espera USD. El error surge en el call site, no en un ledger de producción.
Manejando la Conversión de Forma Explícita
Un sistema de tipos que impidiera toda mezcla sería inutilizable. Los sistemas reales convierten divisas todo el tiempo. El truco es hacer la conversión explícita, trackeada y auditable.
type ExchangeRate<From extends Currency, To extends Currency> = {
readonly from: From;
readonly to: To;
readonly rate: number;
};
function convert<From extends Currency, To extends Currency>(
amount: Money<From>,
rate: ExchangeRate<From, To>
): Money<To> {
return (amount * rate.rate) as Money<To>;
}
Ahora la conversión de divisas es una operación de primera clase con un paper trail a nivel de tipos. No puedes convertir sin un ExchangeRate, y la tasa misma está tipada con las divisas de origen y destino.
const usdToEur: ExchangeRate<"USD", "EUR"> = {
from: "USD",
to: "EUR",
rate: 0.92,
};
const euros = convert(price, usdToEur); // Money<"EUR">
Intenta usar la tasa incorrecta y el compiler te detiene. Intercambia from y to en la definición de la tasa y cada call site que use esa tasa se convierte en un error de tipo. El error se detecta antes de que hagas commit.
La Trampa de la Unidad Principal vs. la Unidad Menor
La divisa no es la única dimensión que puedes modelar. El error de la fintech en 2022 no fue una incompatibilidad de divisas. Fue una incompatibilidad de unidades: dólares versus centavos.
Los phantom types también manejan esto. Añade un segundo parámetro de tipo para la unidad.
type Unit = "major" | "minor";
type Money<C extends Currency, U extends Unit> = number & {
readonly [brand]: { currency: C; unit: U };
};
function money<C extends Currency, U extends Unit>(
amount: number,
_currency: C,
_unit: U
): Money<C, U> {
return amount as Money<C, U>;
}
function toMinor<C extends Currency>(
amount: Money<C, "major">
): Money<C, "minor"> {
return (amount * 100) as Money<C, "minor">;
}
function toMajor<C extends Currency>(
amount: Money<C, "minor">
): Money<C, "major"> {
return (amount / 100) as Money<C, "major">;
}
Ahora el compiler trackea tanto la divisa como la unidad:
const dollars = money(100, "USD", "major");
const cents = money(10000, "USD", "minor");
const bad = add(dollars, cents);
// Error: 'Money<"USD", "minor">' not assignable to 'Money<"USD", "major">'
const fixed = add(dollars, toMajor(cents)); // Money<"USD", "major">, OK
Las funciones de conversión son la única forma de cruzar el boundary de la unidad. Cada cruce es explícito, greppable y revisable.
Dónde Este Patrón Realmente Duele
Los phantom types no son gratuitos. Añaden fricción a las operaciones diarias y no resuelven todos los problemas del dinero.
La serialización es el primer pain point. JSON no tiene concepto de branded types. Cuando haces JSON.stringify de un Money<"USD">, obtienes un number simple. Cuando lo parseas de vuelta, tienes un number simple. Debes validar y re-brandear en cada boundary.
function parseMoney<C extends Currency>(
raw: number,
currency: C
): Money<C> {
if (typeof raw !== "number" || !isFinite(raw)) {
throw new Error("Invalid money value");
}
return money(raw, currency);
}
Las bibliotecas de terceros son el segundo pain point. La mayoría de las bibliotecas de matemáticas, formatting y bases de datos esperan un number simple o un Decimal. Pasarás tiempo escribiendo adapter functions o wrapper types para cerrar la brecha.
El floating point es el tercero. Los ejemplos de arriba usan number, lo que significa que 0.1 + 0.2 !== 0.3. Para software financiero, eso es inaceptable. Deberías almacenar el dinero como un integer de unidades menores, o usar una biblioteca decimal adecuada, y brandear eso en lugar de number.
import { Decimal } from "decimal.js";
type Money<C extends Currency> = Decimal & {
readonly [brand]: C;
};
Una Alternativa Más Simple: Simplemente Usa Objetos
Si los phantom types parecen overkill, un objeto simple con validación en runtime te da la mayor parte de la seguridad con menos maquinaria de tipos.
type Money = {
readonly amount: number;
readonly currency: Currency;
readonly unit: Unit;
};
function add(a: Money, b: Money): Money {
if (a.currency !== b.currency || a.unit !== b.unit) {
throw new Error(
`Cannot add ${a.currency} ${a.unit} to ${b.currency} ${b.unit}`
);
}
return { ...a, amount: a.amount + b.amount };
}
Esto detecta errores en runtime en lugar de compile time. El trade-off es simplicidad. Para herramientas internas, prototipos o equipos nuevos en TypeScript, el enfoque de objetos suele ser el punto de partida correcto. Siempre puedes añadir phantom types más tarde.
Comienza con un Invariante
No necesitas modelar cada divisa y cada unidad desde el día uno. Elige el invariante que te haya quemado antes, brandéalo y enforcealo.
Si tu equipo ha enviado un error de dólares versus centavos, comienza con el tracking de unidades. Si has mezclado divisas en un reporte, comienza con el tracking de divisas. Un branded type, una función de conversión, una garantía en compile time suele ser suficiente para prevenir el próximo error de un millón de dólares.
El sistema de tipos no escribirá tu lógica de tasas de cambio, manejará el rounding correctamente ni te impedirá dividir por la tasa incorrecta. Lo que hará es hacer lo accidental ilegal. Dos valores que nunca deberían encontrarse se negarán a compilar cuando lo hagan. Eso vale los tipos extra.