Une erreur de type à 327 millions de dollars

En 2022, une grande plateforme fintech a traité un virement groupé de 32 700 000,00. Le montant était stocké sous forme de number brut. Un service en aval supposait qu’il était en cents. Ce n’était pas le cas.

Le bug a survécu à la revue de code, aux tests unitaires et aux tests d’intégration. Le système de types a vu number et number et en est resté là. Deux types identiques, parfaitement compatibles, catastrophiquement erronés.

C’est le problème des devises en résumé. Votre système de types empêche d’ajouter une chaîne à un entier. Il n’empêche pas d’ajouter des yens japonais à des dollars américains, ni de traiter un montant en unité majeure comme un montant en unité mineure. Ce sont des quantités sémantiquement différentes, mais dans TypeScript, Rust, Go et la plupart des langages grand public, elles partagent un seul type.

Pourquoi les types numériques natifs échouent avec l’argent

number, f64, int, BigDecimal. Ils encodent tous une magnitude, pas un sens.

Un number peut représenter 100 dollars, 100 cents ou 100 euros. Le système de types traite les trois comme identiques. Vous pouvez les additionner, les comparer et les passer à n’importe quelle fonction attendant un number sans la moindre plainte.

function processPayment(amount: number) {
  // Est-ce des dollars ? Des cents ? Des euros ?
  // Le système de types ne le sait pas, donc il ne peut pas vous aider.
}

const usd = 100        // 100 USD
const cents = 10000    // 10000 cents, aussi 100 USD
const eur = 100        // 100 EUR

processPayment(usd + eur)     // Compile. Faux.
processPayment(usd + cents)   // Compile. Faux aussi.

La défense standard repose sur les conventions de nommage. Appeler la variable amountInCents ou amountUsd. Ça fonctionne jusqu’à ce que quelqu’un fasse du refactoring, copie une valeur au-delà d’une frontière, ou se trompe simplement en lisant le nom. Les commentaires et les conventions de nommage ne sont pas contraignants.

Ce dont vous avez besoin, c’est d’un type qui encode à la fois la valeur numérique et l’unité de devise. Le compilateur devrait rejeter USD + EUR de la même manière qu’il rejette string + number.

Types fantômes : apprendre au compilateur ce qu’est une devise

La technique qui fonctionne s’appelle le type fantôme. Vous définissez un wrapper générique autour d’un nombre où le paramètre de type porte l’étiquette de la devise. Le paramètre de type n’apparaît jamais à l’exécution, mais le compilateur l’utilise pour imposer des contraintes.

Voici une implémentation complète et fonctionnelle 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>;
}

Le symbole brand crée une distinction de type nominale. Deux valeurs Money avec des marques de devise différentes sont incompatibles, même si les deux sont des number en dessous.

L’utilisation se présente ainsi :

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

Le compilateur comprend désormais que USD et EUR sont des choses différentes. Vous ne pouvez pas les additionner par accident. Vous ne pouvez pas passer EUR à une fonction attendant USD. L’erreur apparaît au site d’appel, pas dans un grand livre de production.

Gérer la conversion explicitement

Un système de types qui empêcherait tout mélange serait inutilisable. Les systèmes réels convertissent des devises tout le temps. L’astuce consiste à rendre la conversion explicite, traçable et 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>;
}

La conversion de devises est désormais une opération de première classe avec une trace papier au niveau du type. Vous ne pouvez pas convertir sans un ExchangeRate, et le taux lui-même est typé avec les devises source et destination.

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

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

Essayez d’utiliser le mauvais taux et le compilateur vous arrête. Inversez from et to dans la définition du taux et chaque site d’appel utilisant ce taux devient une erreur de type. Le bug est attrapé avant que vous ne committiez.

Le piège de l’unité majeure contre l’unité mineure

La devise n’est pas la seule dimension que vous pouvez modéliser. Le bug fintech de 2022 n’était pas une incompatibilité de devise. C’était une incompatibilité d’unité : dollars contre cents.

Les types fantômes gèrent cela aussi. Ajoutez un deuxième paramètre de type pour l’unité.

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

Le compilateur trace désormais à la fois la devise et l’unité :

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

Les fonctions de conversion sont le seul moyen de franchir la frontière de l’unité. Chaque franchissement est explicite, grepable et révisable.

Où ce pattern fait réellement mal

Les types fantômes ne sont pas gratuits. Ils ajoutent de la friction aux opérations quotidiennes et ils ne résolvent pas tous les problèmes d’argent.

La sérialisation est le premier point douloureux. JSON n’a aucune notion de types marqués. Quand vous faites JSON.stringify sur un Money<"USD">, vous obtenez un nombre brut. Quand vous le parsez en retour, vous avez un nombre brut. Vous devez valider et réappliquer la marque à chaque frontière.

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

Les bibliothèques tierces sont le deuxième point douloureux. La plupart des bibliothèques de mathématiques, de formatage et de bases de données attendent un number ou un Decimal brut. Vous passerez du temps à écrire des fonctions d’adaptation ou des wrapper types pour combler le fossé.

La virgule flottante est la troisième. Les exemples ci-dessus utilisent number, ce qui signifie que 0.1 + 0.2 !== 0.3. Pour un logiciel financier, c’est inacceptable. Vous devriez stocker l’argent comme un nombre entier d’unités mineures, ou utiliser une bibliothèque décimale appropriée, et marquer cela au lieu de number.

import { Decimal } from "decimal.js";

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

Une alternative plus simple : utilisez simplement des objets

Si les types fantômes semblent excessifs, un objet simple avec validation à l’exécution vous apporte la plupart de la sécurité avec moins de machinerie de types.

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

Cela attrape les erreurs à l’exécution plutôt qu’à la compilation. Le compromis est la simplicité. Pour les outils internes, les prototypes ou les équipes nouvelles à TypeScript, l’approche par objet est souvent le bon point de départ. Vous pouvez toujours ajouter des types fantômes plus tard.

Commencez par un invariant

Vous n’avez pas besoin de modéliser chaque devise et chaque unité dès le premier jour. Choisissez l’invariant unique qui vous a déjà brûlé, marquez-le et imposez-le.

Si votre équipe a livré un bug dollars-contre-cents, commencez par le suivi des unités. Si vous avez mélangé des devises dans un rapport, commencez par le suivi des devises. Un type marqué, une fonction de conversion, une garantie à la compilation suffisent souvent à empêcher la prochaine erreur à un million de dollars.

Le système de types n’écrira pas votre logique de taux de change, ne gérera pas correctement l’arrondi ni ne vous empêchera de diviser par le mauvais taux. Ce qu’il fera, c’est rendre l’accidentel illégal. Deux valeurs qui ne devraient jamais se rencontrer refuseront de compiler quand elles le feront. Ça vaut le coup des types supplémentaires.