Kesalahan Tipe Senilai 327 Juta Dolar

Pada tahun 2022, platform fintech besar memproses transfer massal sebesar 32.700.000,00. Jumlah tersebut disimpan sebagai number biasa. Layanan hilir menganggapnya dalam satuan sen. Padahal bukan.

Bug tersebut lolos dari code review, unit test, dan integration test. Sistem tipe melihat number dan number, lalu menganggapnya selesai. Dua tipe identik, sepenuhnya kompatibel, secara katastrofal salah.

Inilah inti masalah mata uang. Sistem tipe Anda mencegah penambahan string ke integer. Namun ia tidak mencegah penambahan yen Jepang ke dolar AS, atau menganggap jumlah dalam unit besar sebagai unit kecil. Itu adalah kuantitas yang secara semantik berbeda, tetapi di TypeScript, Rust, Go, dan sebagian besar bahasa utama lainnya, mereka berbagi satu tipe.

Mengapa Tipe Number Asli Gagal Menangani Uang

number, f64, int, BigDecimal. Semuanya meng-encode magnitude, bukan makna.

Sebuah number bisa mewakili 100 dolar, 100 sen, atau 100 euro. Sistem tipe menganggap ketiganya identik. Anda bisa menambahkannya, membandingkannya, dan meneruskannya ke fungsi apa pun yang mengharapkan number tanpa sedikit pun keluhan.

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.

Pertahanan standarnya adalah konvensi penamaan. Namai variabel amountInCents atau amountUsd. Itu berfungsi sampai seseorang me-refactor, menyalin nilai melintasi batasan, atau sekadar salah membaca nama. Komentar dan konvensi penamaan tidak dapat ditegakkan.

Yang Anda butuhkan adalah tipe yang meng-encode baik nilai numerik maupun unit mata uang. Compiler seharusnya menolak USD + EUR dengan cara yang sama seperti menolak string + number.

Phantom Types: Mengajari Compiler Tentang Mata Uang

Teknik yang berhasil disebut phantom type. Anda mendefinisikan generic wrapper di sekitar number di mana type parameter membawa label mata uang. Type parameter tidak pernah muncul saat runtime, tetapi compiler menggunakannya untuk menegakkan constraint.

Berikut adalah implementasi lengkap yang berfungsi di 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>;
}

Simbol brand menciptakan perbedaan tipe nominal. Dua nilai Money dengan brand mata uang yang berbeda tidak kompatibel, meskipun keduanya adalah number di bawahnya.

Penggunaannya terlihat seperti ini:

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

Compiler kini memahami bahwa USD dan EUR adalah hal yang berbeda. Anda tidak bisa menambahkannya secara tidak sengaja. Anda tidak bisa meneruskan EUR ke fungsi yang mengharapkan USD. Error muncul di call site, bukan di ledger produksi.

Menangani Konversi Secara Eksplisit

Sistem tipe yang mencegah semua pencampuran akan tidak dapat digunakan. Sistem nyata mengkonversi mata uang sepanjang waktu. Triknya adalah membuat konversi eksplisit, terlacak, dan dapat diaudit.

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

Kini konversi mata uang adalah operasi first-class dengan jejak kertas di level tipe. Anda tidak bisa mengkonversi tanpa ExchangeRate, dan rate itu sendiri diberi tipe dengan mata uang sumber dan tujuan.

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

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

Coba gunakan rate yang salah dan compiler akan menghentikan Anda. Tukar from dan to dalam definisi rate dan setiap call site yang menggunakan rate tersebut menjadi type error. Bug tertangkap sebelum Anda commit.

Jebakan Unit Besar versus Unit Kecil

Mata uang bukanlah satu-satunya dimensi yang bisa Anda modelkan. Bug fintech 2022 bukanlah ketidakcocokan mata uang. Itu adalah ketidakcocokan unit: dolar versus sen.

Phantom type juga menangani ini. Tambahkan type parameter kedua untuk 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">;
}

Kini compiler melacak baik mata uang maupun 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

Fungsi konversi adalah satu-satunya cara untuk melintasi batas unit. Setiap lintasan eksplisit, dapat di-grep, dan dapat di-review.

Di Mana Pola Ini Sebenarnya Menyulitkan

Phantom type tidak gratis. Ia menambah gesekan pada operasi sehari-hari dan tidak menyelesaikan setiap masalah uang.

Serialization adalah pain point pertama. JSON tidak punya konsep branded type. Ketika Anda JSON.stringify sebuah Money<"USD">, Anda mendapat number biasa. Ketika Anda parse kembali, Anda memiliki number biasa. Anda harus memvalidasi dan me-re-brand di setiap batasan.

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

Library pihak ketiga adalah pain point kedua. Sebagian besar library math, formatting, dan database mengharapkan number atau Decimal biasa. Anda akan menghabiskan waktu menulis fungsi adapter atau tipe wrapper untuk menjembatani kesenjangan.

Floating point adalah yang ketiga. Contoh di atas menggunakan number, yang berarti 0.1 + 0.2 !== 0.3. Untuk perangkat lunak finansial, itu tidak dapat diterima. Anda seharusnya menyimpan uang sebagai integer jumlah unit kecil, atau menggunakan library decimal yang tepat, dan memberi brand pada itu alih-alih number.

import { Decimal } from "decimal.js";

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

Alternatif Lebih Sederhana: Gunakan Saja Objek

Jika phantom type terasa berlebihan, objek biasa dengan validasi runtime memberi Anda sebagian besar keamanan dengan lebih sedikit mesin tipe.

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

Ini menangkap error saat runtime alih-alih compile time. Komprominya adalah kesederhanaan. Untuk tools internal, prototipe, atau tim yang baru mengenal TypeScript, pendekatan objek sering kali adalah titik awal yang tepat. Anda selalu bisa menambahkan phantom type nanti.

Mulai dengan Satu Invarian

Anda tidak perlu memodelkan setiap mata uang dan setiap unit sejak hari pertama. Pilih satu invarian yang pernah membuat Anda terbakar sebelumnya, beri brand, dan tegakkan.

Jika tim Anda pernah merilis bug dolar-versus-sen, mulailah dengan pelacakan unit. Jika Anda pernah mencampur mata uang dalam laporan, mulailah dengan pelacakan mata uang. Satu branded type, satu fungsi konversi, satu jaminan compile-time sering kali cukup untuk mencegah kesalahan berikutnya yang bernilai jutaan dolar.

Sistem tipe tidak akan menulis logika exchange rate Anda, menangani pembulatan dengan benar, atau menghentikan Anda membagikan dengan rate yang salah. Yang akan dilakukannya adalah membuat yang tidak sengaja menjadi ilegal. Dua nilai yang seharusnya tidak pernah bertemu akan menolak untuk di-compile ketika mereka bertemu. Itu sepadan dengan tipe tambahan.