Ein Type-Error im Wert von 327 Millionen Dollar
2022 verarbeitete eine große Fintech-Plattform einen Massentransfer über 32.700.000,00. Der Betrag war als einfacher number gespeichert. Ein nachgelagerter Service ging davon aus, dass er in Cents angegeben war. Das war er nicht.
Der Bug überlebte Code Review, Unit-Tests und integration tests. Das Typsystem sah number und number und war zufrieden. Zwei identische Typen, perfekt kompatibel, katastrophal falsch.
Das ist das Currency-Problem in Kürze. Dein Typsystem verhindert, dass du einen String zu einem Integer addierst. Es verhindert nicht, dass du japanische Yen zu US-Dollar addierst, oder einen Major-Unit-Betrag wie einen Minor-Unit-Betrag behandelst. Das sind semantisch unterschiedliche Größen, aber in TypeScript, Rust, Go und den meisten Mainstream-Sprachen teilen sie sich einen einzigen Typ.
Warum native Number-Typen bei Geld versagen
number, f64, int, BigDecimal. Sie alle kodieren Größe, nicht Bedeutung.
Ein number kann 100 Dollar, 100 Cents oder 100 Euro repräsentieren. Das Typsystem behandelt alle drei als identisch. Du kannst sie addieren, vergleichen und an jede Funktion übergeben, die ein number erwartet – ohne eine einzige Beschwerde.
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.
Die Standardverteidigung sind Naming Conventions. Nenne die Variable amountInCents oder amountUsd. Das funktioniert, bis jemand refactort, einen Wert über eine Grenze hinweg kopiert oder den Namen einfach falsch liest. Kommentare und Naming Conventions sind nicht durchsetzbar.
Was du brauchst, ist ein Typ, der sowohl den numerischen Wert als auch die Währungseinheit kodiert. Der Compiler sollte USD + EUR ablehnen, genauso wie er string + number ablehnt.
Phantom Types: Dem Compiler Währung beibringen
Die Technik, die funktioniert, nennt man Phantom Type. Du definierst einen generischen Wrapper um eine Zahl, bei dem der Typparameter das Währungslabel trägt. Der Typparameter erscheint niemals zur Laufzeit, aber der Compiler nutzt ihn, um Constraints durchzusetzen.
Hier ist eine vollständige, funktionierende Implementierung in 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>;
}
Das brand-Symbol erzeugt eine nominale Typunterscheidung. Zwei Money-Werte mit unterschiedlichen Währungs-Brands sind inkompatibel, auch wenn beide darunter number sind.
Die Verwendung sieht so aus:
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">'.
Der Compiler versteht jetzt, dass USD und EUR unterschiedliche Dinge sind. Du kannst sie nicht versehentlich addieren. Du kannst EUR nicht an eine Funktion übergeben, die USD erwartet. Der Fehler taucht am Call Site auf, nicht in einem Produktions-Ledger.
Konversion explizit behandeln
Ein Typsystem, das jegliches Mischen verhindert, wäre unbrauchbar. Echte Systeme konvertieren ständig Währungen. Der Trick besteht darin, die Konversion explizit, nachvollziehbar und auditierbar zu machen.
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>;
}
Währungskonversion ist jetzt eine First-Class-Operation mit einem Paper Trail auf Type-Ebene. Du kannst nicht konvertieren, ohne einen ExchangeRate, und der Rate selbst ist mit Quell- und Zielwährung getypt.
const usdToEur: ExchangeRate<"USD", "EUR"> = {
from: "USD",
to: "EUR",
rate: 0.92,
};
const euros = convert(price, usdToEur); // Money<"EUR">
Versuche, den falschen Rate zu verwenden, und der Compiler stoppt dich. Tausche from und to in der Rate-Definition und jeder Call Site, der diesen Rate verwendet, wird zu einem Type Error. Der Bug wird erfasst, bevor du committest.
Die Major-Unit-vs.-Minor-Unit-Falle
Währung ist nicht die einzige Dimension, die du modellieren kannst. Der Fintech-Bug von 2022 war kein Currency Mismatch. Es war ein Unit Mismatch: Dollar versus Cents.
Phantom Types können das auch. Füge einen zweiten Typparameter für die Einheit hinzu.
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">;
}
Jetzt trackt der Compiler sowohl Währung als auch Einheit:
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
Die Konversionsfunktionen sind der einzige Weg, die Unit-Grenze zu überschreiten. Jede Überschreitung ist explizit, greppbar und reviewbar.
Wo dieses Pattern wirklich wehtut
Phantom Types sind nicht kostenlos. Sie erhöhen den Reibungskoeffizienten bei alltäglichen Operationen und lösen nicht jedes Geldproblem.
Serialisierung ist der erste Pain Point. JSON hat kein Konzept von branded types. Wenn du JSON.stringify auf ein Money<"USD"> anwendest, erhältst du eine einfache Zahl. Wenn du sie zurückparst, hast du eine einfache Zahl. Du musst an jeder Grenze validieren und re-branden.
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);
}
Third-Party-Libraries sind der zweite Pain Point. Die meisten Math-, Formatting- und Database-Libraries erwarten einfachen number oder Decimal. Du wirst Zeit damit verbringen, Adapter-Funktionen oder Wrapper-Typen zu schreiben, um die Lücke zu schließen.
Floating Point ist der dritte. Die Beispiele oben verwenden number, was bedeutet, dass 0.1 + 0.2 !== 0.3. Für Finanzsoftware ist das inakzeptabel. Du solltest Geld als ganzzahlige Anzahl von Minor Units speichern oder eine ordentliche Decimal-Library verwenden und stattdessen die branden.
import { Decimal } from "decimal.js";
type Money<C extends Currency> = Decimal & {
readonly [brand]: C;
};
Eine einfachere Alternative: Einfach Objekte verwenden
Wenn Phantom Types wie Overkill wirken, liefert ein einfaches Objekt mit Runtime-Validation den Großteil der Sicherheit mit weniger Type-Machinery.
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 };
}
Das fängt Fehler zur Laufzeit statt zur Compile-Zeit ab. Der Trade-off ist Einfachheit. Für interne Tools, Prototypen oder Teams, die neu in TypeScript sind, ist der Objekt-Ansatz oft der richtige Einstieg. Du kannst später immer noch Phantom Types hinzufügen.
Fange mit einer Invariante an
Du musst nicht an Tag eins jede Währung und jede Einheit modellieren. Wähle die eine Invariante, die dich schon einmal gebrannt hat, brande sie und erzwinge sie.
Wenn dein Team schon einmal einen Dollar-vs.-Cents-Bug ausgeliefert hat, fange mit Unit Tracking an. Wenn du in einem Report Währungen gemischt hast, fange mit Currency Tracking an. Ein branded type, eine Konversionsfunktion, eine Compile-Zeit-Garantie reichen oft aus, um den nächsten Millionen-Dollar-Fehler zu verhindern.
Das Typsystem wird deine Exchange-Rate-Logik nicht schreiben, korrekt runden oder dich daran hindern, durch den falschen Rate zu dividieren. Was es tun wird, ist, das Zufällige illegal zu machen. Zwei Werte, die sich niemals begegnen sollten, werden sich weigern zu kompilieren, wenn sie es doch tun. Das ist die extra Types wert.