Um Erro de Tipo de US$ 327 Milhões

Em 2022, uma grande plataforma fintech processou uma transferência em massa de 32.700.000,00. O valor estava armazenado como um number simples. Um serviço downstream assumiu que estava em centavos. Não estava.

O bug sobreviveu à code review, aos unit tests e aos integration tests. O sistema de tipos viu number e number e deu o caso por encerrado. Dois tipos idênticos, perfeitamente compatíveis, catastroficamente errados.

Esse é o problema da moeda em poucas palavras. Seu sistema de tipos impede que você some uma string a um inteiro. Mas ele não impede que você some ienes japoneses a dólares americanos, nem que trate um valor em unidade principal como um valor em unidade menor. São quantidades semanticamente diferentes, mas em TypeScript, Rust, Go e na maioria das linguagens mainstream, elas compartilham um único tipo.

Por Que Tipos Numéricos Nativos Falham com Dinheiro

number, f64, int, BigDecimal. Todos eles codificam magnitude, não significado.

Um number pode representar 100 dólares, 100 centavos ou 100 euros. O sistema de tipos trata os três como idênticos. Você pode somá-los, compará-los e passá-los para qualquer função que espere um number sem uma única reclamação.

function processPayment(amount: number) {
  // Isso é dólares? Centavos? Euros?
  // O sistema de tipos não sabe, então não pode ajudar.
}

const usd = 100        // 100 USD
const cents = 10000    // 10000 centavos, também 100 USD
const eur = 100        // 100 EUR

processPayment(usd + eur)     // Compila. Errado.
processPayment(usd + cents)   // Compila. Também errado.

A defesa padrão são as convenções de nomenclatura. Chame a variável de amountInCents ou amountUsd. Isso funciona até alguém fazer refactor, copiar um valor através de uma fronteira ou simplesmente ler o nome errado. Comentários e convenções de nomenclatura não são exequíveis.

O que você precisa é de um tipo que codifique tanto o valor numérico quanto a unidade monetária. O compilador deveria rejeitar USD + EUR da mesma forma que rejeita string + number.

Phantom Types: Ensinando o Compilador Sobre Moeda

A técnica que funciona é chamada de phantom type. Você define um wrapper genérico em torno de um número em que o parâmetro de tipo carrega o rótulo da moeda. O parâmetro de tipo nunca aparece em tempo de execução, mas o compilador o usa para aplicar restrições.

Aqui está uma implementação completa e funcional em 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>;
}

O símbolo brand cria uma distinção de tipo nominal. Dois valores Money com marcas de moeda diferentes são incompatíveis, mesmo que ambos sejam number por baixo.

O uso fica assim:

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">'.

O compilador agora entende que USD e EUR são coisas diferentes. Você não pode somá-los por acidente. Não pode passar EUR para uma função que espera USD. O erro aparece no local da chamada, não em um livro-razão de produção.

Lidando com Conversão de Forma Explícita

Um sistema de tipos que impede toda mistura seria inutilizável. Sistemas reais convertem moedas o tempo todo. O truque é tornar a conversão explícita, rastreável e auditável.

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>;
}

Agora a conversão de moeda é uma operação de primeira classe com um rastro em nível de tipo. Você não pode converter sem uma ExchangeRate, e a própria taxa é tipada com as moedas de origem e destino.

const usdToEur: ExchangeRate<"USD", "EUR"> = {
  from: "USD",
  to: "EUR",
  rate: 0.92,
};

const euros = convert(price, usdToEur); // Money<"EUR">

Tente usar a taxa errada e o compilador o impede. Troque from e to na definição da taxa e todo local de chamada que usa essa taxa se torna um erro de tipo. O bug é capturado antes do commit.

A Armadilha da Unidade Principal Versus Unidade Menor

Moeda não é a única dimensão que você pode modelar. O bug da fintech em 2022 não foi uma incompatibilidade de moeda. Foi uma incompatibilidade de unidade: dólares versus centavos.

Os phantom types lidam com isso também. Adicione um segundo parâmetro de tipo para a unidade.

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">;
}

Agora o compilador rastreia tanto a moeda quanto a unidade:

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

As funções de conversão são a única forma de atravessar a fronteira da unidade. Cada travessia é explícita, pesquisável e passível de revisão.

Onde Esse Padrão Realmente Dói

Phantom types não são de graça. Eles adicionam atrito às operações do dia a dia e não resolvem todos os problemas com dinheiro.

Serialização é o primeiro ponto de dor. JSON não tem conceito de tipos marcados. Quando você faz JSON.stringify em um Money<"USD">, obtém um número simples. Quando o parseia de volta, tem um número simples. Você deve validar e remarcar em toda fronteira.

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);
}

Bibliotecas de terceiros são o segundo ponto de dor. A maioria das bibliotecas de matemática, formatação e banco de dados espera number simples ou Decimal. Você vai gastar tempo escrevendo funções adaptadoras ou tipos wrapper para fazer a ponte.

Ponto flutuante é o terceiro. Os exemplos acima usam number, o que significa que 0.1 + 0.2 !== 0.3. Para software financeiro, isso é inaceitável. Você deve armazenar dinheiro como um número inteiro de unidades menores, ou usar uma biblioteca decimal adequada, e marcar isso em vez de number.

import { Decimal } from "decimal.js";

type Money<C extends Currency> = Decimal & {
  readonly [brand]: C;
};

Uma Alternativa Mais Simples: Apenas Use Objetos

Se phantom types parecerem exagero, um objeto simples com validação em tempo de execução oferece a maior parte da segurança com 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 };
}

Isso captura erros em tempo de execução em vez de tempo de compilação. O trade-off é simplicidade. Para ferramentas internas, protótipos ou equipes novas em TypeScript, a abordagem com objeto geralmente é o ponto de partida certo. Você sempre pode adicionar phantom types depois.

Comece com Uma Invariante

Você não precisa modelar toda moeda e toda unidade no primeiro dia. Escolha a invariante que já te queimou antes, marque-a e aplique-a.

Se sua equipe já enviou um bug de dólares versus centavos, comece com o rastreamento de unidade. Se você misturou moedas em um relatório, comece com o rastreamento de moeda. Um tipo marcado, uma função de conversão, uma garantia em tempo de compilação geralmente é suficiente para prevenir o próximo erro de milhões de dólares.

O sistema de tipos não vai escrever sua lógica de taxa de câmbio, lidar corretamente com arredondamento ou impedir que você divida pela taxa errada. O que ele vai fazer é tornar o acidental ilegal. Dois valores que nunca deveriam se encontrar vão se recusar a compilar quando o fizerem. Isso vale os tipos extras.