Type Safety in Fintech: Why Precision Matters More Than Speed
An exploration of strongly-typed languages and tools for building reliable financial systems
Type Safety in Fintech: Why Precision Matters More Than Speed
In fintech, a misplaced decimal or integer overflow isn't just a bug—it's a potential regulatory violation, a lost customer, or worse. While the broader tech industry debates the merits of static typing, financial systems have already made the choice: precision and correctness trump development velocity.
This article examines the typed language ecosystem for building production-grade fintech applications, from frontend to database.
Why Type Safety is Non-Negotiable in Finance
The Cost of Runtime Errors
// This compiles in JavaScript/untyped systems
const balance = "1000.50";
const fee = 2.99;
const remaining = balance - fee; // NaN in JS, chaos in production
In traditional applications, this might cause a UI glitch. In fintech, this could:
- Violate regulatory reporting requirements (SOX, PCI-DSS)
- Create impossible-to-reconcile ledger states
- Expose the company to legal liability
- Erode customer trust irreparably
Compile-Time Guarantees
Strong type systems catch entire classes of errors before they reach production:
- Null reference errors: Eliminated via strict null checking
- Type coercion bugs: Prevented through explicit conversions
- Invalid state transitions: Enforced through algebraic data types
- Precision loss: Caught when mixing decimal/float types
Frontend: TypeScript and Beyond
TypeScript as the Baseline
TypeScript has become the de facto standard for fintech frontends, and for good reason:
interface MoneyAmount {
readonly value: string; // Store as string to avoid float precision issues
readonly currency: CurrencyCode;
}
type CurrencyCode = "USD" | "EUR" | "GBP" | "JPY";
interface Transaction {
id: string;
amount: MoneyAmount;
timestamp: Date;
status: "pending" | "completed" | "failed" | "reversed";
}
// Type-safe transaction processing
function processTransaction(tx: Transaction): Result<Receipt, ProcessingError> {
// Compiler enforces all status values are handled
switch (tx.status) {
case "pending":
return executePending(tx);
case "completed":
return Result.ok(generateReceipt(tx));
case "failed":
case "reversed":
return Result.err({ code: "INVALID_STATE", tx: tx.id });
}
}
Key configurations for fintech projects:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
}
}
Decimal.js: Solving the Float Problem
JavaScript's Number type uses IEEE 754 double-precision floats, which are fundamentally incompatible with financial calculations:
// The classic JavaScript nightmare
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004
Enter Decimal.js:
import Decimal from "decimal.js";
// Configure for financial precision
Decimal.set({
precision: 20,
rounding: Decimal.ROUND_HALF_UP,
toExpNeg: -7,
toExpPos: 21,
});
class Money {
private readonly amount: Decimal;
public readonly currency: CurrencyCode;
constructor(value: string | number, currency: CurrencyCode) {
this.amount = new Decimal(value);
this.currency = currency;
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amount.plus(other.amount).toString(), this.currency);
}
multiply(factor: number | string): Money {
return new Money(this.amount.times(factor).toString(), this.currency);
}
toFixed(decimals: number = 2): string {
return this.amount.toFixed(decimals);
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error(
`Currency mismatch: ${this.currency} vs ${other.currency}`
);
}
}
}
// Usage
const price = new Money("99.99", "USD");
const tax = price.multiply("0.0875"); // 8.75% tax
const total = price.add(tax);
console.log(total.toFixed(2)); // "108.74" - precisely
Alternative: ReScript/ReasonML
For teams willing to invest in stronger guarantees, ReScript offers:
- Sound type system (no
anyescape hatch) - Pattern matching for exhaustive case handling
- Compiles to readable JavaScript/TypeScript
- Immutability by default
type currency = USD | EUR | GBP
type money = {value: Decimal.t, currency: currency}
type transactionStatus =
| Pending
| Completed(DateTime.t)
| Failed(string)
| Reversed(string, DateTime.t)
let processTransaction = (status: transactionStatus) =>
switch status {
| Pending => Error("Cannot process pending transaction")
| Completed(timestamp) => Ok(timestamp)
| Failed(reason) => Error(`Transaction failed: ${reason}`)
| Reversed(reason, _) => Error(`Transaction was reversed: ${reason}`)
}
// Compiler error if you don't handle all cases
Serialization: Protocol Buffers vs JSON
The JSON Problem
{
"amount": 1234567890.12,
"balance": 0.1
}
Issues:
- Precision loss: Large integers lose precision beyond
Number.MAX_SAFE_INTEGER(2^53 - 1) - No schema validation: Fields can be missing, misspelled, or wrong type
- Parsing cost: JSON parsing is CPU-intensive at scale
- No backward compatibility guarantees
Protocol Buffers: A Better Contract
syntax = "proto3";
message MoneyAmount {
// Store as string or use fixed-point integer (cents)
int64 amount_cents = 1; // $123.45 = 12345 cents
string currency_code = 2;
}
message Transaction {
string id = 1;
MoneyAmount amount = 2;
int64 timestamp_unix = 3;
TransactionStatus status = 4;
}
enum TransactionStatus {
TRANSACTION_STATUS_UNSPECIFIED = 0;
TRANSACTION_STATUS_PENDING = 1;
TRANSACTION_STATUS_COMPLETED = 2;
TRANSACTION_STATUS_FAILED = 3;
TRANSACTION_STATUS_REVERSED = 4;
}
Advantages:
- Type safety across services: Generate TypeScript/C#/Go clients from same
.protofile - Backward compatibility: Built-in versioning via field numbers
- Smaller payloads: Binary format ~3-10x smaller than JSON
- Faster parsing: 20-100x faster than JSON in benchmarks
- Schema enforcement: Invalid messages rejected at parse time
TypeScript usage:
import { Transaction, TransactionStatus } from "./generated/transaction_pb";
const tx = new Transaction();
tx.setId("txn_abc123");
tx.setAmount(new MoneyAmount().setAmountCents(12345).setCurrencyCode("USD"));
tx.setTimestampUnix(Date.now());
tx.setStatus(TransactionStatus.TRANSACTION_STATUS_PENDING);
// Serialize to binary
const bytes: Uint8Array = tx.serializeBinary();
// Send over wire...
// Deserialize
const received = Transaction.deserializeBinary(bytes);
console.log(received.getAmount()?.getAmountCents()); // Type-safe accessors
Backend: C# and Alternatives
C# with .NET: The Enterprise Standard
C# offers exceptional tooling for fintech:
using System;
// Value objects for type safety
public record Currency(string Code)
{
public static Currency USD = new("USD");
public static Currency EUR = new("EUR");
}
public record Money(decimal Amount, Currency Currency)
{
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException(
$"Cannot add {Currency.Code} to {other.Currency.Code}"
);
return this with { Amount = Amount + other.Amount };
}
public Money Multiply(decimal factor) =>
this with { Amount = Amount * factor };
}
// Sum types via discriminated unions (C# 9+)
public abstract record TransactionStatus
{
public record Pending : TransactionStatus;
public record Completed(DateTime Timestamp) : TransactionStatus;
public record Failed(string Reason) : TransactionStatus;
public record Reversed(string Reason, DateTime Timestamp) : TransactionStatus;
}
public class TransactionProcessor
{
public Result<Receipt> Process(TransactionStatus status)
{
return status switch
{
TransactionStatus.Pending =>
Result<Receipt>.Failure("Cannot process pending transaction"),
TransactionStatus.Completed(var timestamp) =>
Result<Receipt>.Success(GenerateReceipt(timestamp)),
TransactionStatus.Failed(var reason) =>
Result<Receipt>.Failure($"Transaction failed: {reason}"),
TransactionStatus.Reversed(var reason, _) =>
Result<Receipt>.Failure($"Transaction reversed: {reason}"),
_ => throw new ArgumentOutOfRangeException()
};
}
}
Why C# excels:
- Native
decimaltype: 128-bit, base-10, perfect for money - Null safety: Nullable reference types (enabled via
<Nullable>enable</Nullable>) - Performance: Near-native speed with modern .NET runtime
- Mature ecosystem: Entity Framework, mass transit, robust logging
Alternative: Rust
For maximum performance and safety:
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Currency {
USD,
EUR,
GBP,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Money {
amount: Decimal,
currency: Currency,
}
impl Money {
pub fn new(amount: Decimal, currency: Currency) -> Self {
Self { amount, currency }
}
pub fn add(&self, other: &Money) -> Result<Money, MoneyError> {
if self.currency != other.currency {
return Err(MoneyError::CurrencyMismatch);
}
Ok(Money::new(self.amount + other.amount, self.currency))
}
}
#[derive(Debug)]
pub enum TransactionStatus {
Pending,
Completed { timestamp: i64 },
Failed { reason: String },
Reversed { reason: String, timestamp: i64 },
}
// Exhaustive pattern matching enforced by compiler
fn process_transaction(status: TransactionStatus) -> Result<Receipt, String> {
match status {
TransactionStatus::Pending =>
Err("Cannot process pending".to_string()),
TransactionStatus::Completed { timestamp } =>
Ok(generate_receipt(timestamp)),
TransactionStatus::Failed { reason } =>
Err(format!("Failed: {}", reason)),
TransactionStatus::Reversed { reason, .. } =>
Err(format!("Reversed: {}", reason)),
}
}
Rust advantages:
- Zero-cost abstractions: Type safety with no runtime overhead
- No garbage collection: Deterministic performance
- Ownership system: Prevents data races and memory leaks
rust_decimalcrate: Equivalent to C#'s decimal type
Alternative: F#
For teams preferring functional paradigms:
type Currency = USD | EUR | GBP
type Money = {
Amount: decimal
Currency: Currency
}
type TransactionStatus =
| Pending
| Completed of timestamp:DateTime
| Failed of reason:string
| Reversed of reason:string * timestamp:DateTime
let addMoney (m1: Money) (m2: Money) : Result<Money, string> =
if m1.Currency <> m2.Currency then
Error "Currency mismatch"
else
Ok { Amount = m1.Amount + m2.Amount; Currency = m1.Currency }
let processTransaction status =
match status with
| Pending -> Error "Cannot process pending"
| Completed timestamp -> Ok (generateReceipt timestamp)
| Failed reason -> Error $"Failed: {reason}"
| Reversed (reason, _) -> Error $"Reversed: {reason}"
Database: PostgreSQL and Type Safety
Why PostgreSQL for Fintech
- ACID compliance: True transactional integrity
- NUMERIC type: Arbitrary precision decimal arithmetic
- Strong typing: Column constraints enforced at DB level
- JSON/JSONB support: Flexibility when needed, with schema validation
- Mature replication: Battle-tested for high availability
Schema Design for Type Safety
-- Enum types for status fields
CREATE TYPE currency_code AS ENUM ('USD', 'EUR', 'GBP', 'JPY');
CREATE TYPE transaction_status AS ENUM ('pending', 'completed', 'failed', 'reversed');
-- Use NUMERIC for all monetary values
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
balance NUMERIC(19, 4) NOT NULL CHECK (balance >= 0),
currency currency_code NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT positive_balance CHECK (balance >= 0)
);
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_account_id UUID REFERENCES accounts(id),
to_account_id UUID REFERENCES accounts(id),
amount NUMERIC(19, 4) NOT NULL CHECK (amount > 0),
currency currency_code NOT NULL,
status transaction_status NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
CONSTRAINT valid_accounts CHECK (
from_account_id IS NOT NULL OR to_account_id IS NOT NULL
),
CONSTRAINT completed_timestamp CHECK (
(status = 'completed' AND completed_at IS NOT NULL) OR
(status != 'completed' AND completed_at IS NULL)
)
);
-- Indexes for performance
CREATE INDEX idx_accounts_user_currency ON accounts(user_id, currency);
CREATE INDEX idx_transactions_status ON transactions(status) WHERE status = 'pending';
CREATE INDEX idx_transactions_created ON transactions(created_at DESC);
Type-Safe Queries with C#
Using Dapper or Entity Framework:
public class Account
{
public Guid Id { get; init; }
public Guid UserId { get; init; }
public decimal Balance { get; init; }
public Currency Currency { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
public class AccountRepository
{
private readonly NpgsqlConnection _connection;
public async Task<Account?> GetByIdAsync(Guid id)
{
const string sql = @"
SELECT id, user_id, balance, currency, created_at
FROM accounts
WHERE id = @Id";
return await _connection.QuerySingleOrDefaultAsync<Account>(
sql,
new { Id = id }
);
}
public async Task<bool> TransferAsync(
Guid fromAccountId,
Guid toAccountId,
decimal amount,
Currency currency)
{
using var transaction = await _connection.BeginTransactionAsync();
try
{
// Debit source account
const string debit = @"
UPDATE accounts
SET balance = balance - @Amount, updated_at = now()
WHERE id = @AccountId
AND currency = @Currency
AND balance >= @Amount
RETURNING balance";
var newBalance = await _connection.ExecuteScalarAsync<decimal?>(
debit,
new { AccountId = fromAccountId, Amount = amount, Currency = currency },
transaction
);
if (newBalance == null)
{
await transaction.RollbackAsync();
return false;
}
// Credit destination account
const string credit = @"
UPDATE accounts
SET balance = balance + @Amount, updated_at = now()
WHERE id = @AccountId AND currency = @Currency";
await _connection.ExecuteAsync(
credit,
new { AccountId = toAccountId, Amount = amount, Currency = currency },
transaction
);
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
PostgreSQL Constraints as Runtime Type Checks
The database acts as a final enforcement layer:
-- This will fail at INSERT time, not at application runtime
INSERT INTO accounts (balance, currency)
VALUES (-100, 'USD');
-- ERROR: new row violates check constraint "positive_balance"
-- This will fail due to enum constraint
INSERT INTO accounts (balance, currency)
VALUES (100, 'INVALID');
-- ERROR: invalid input value for enum currency_code: "INVALID"
Putting It All Together: End-to-End Type Safety
Key principles:
- Parse, don't validate: Convert untyped input (JSON, form data) to strongly-typed objects at the boundary
- Make illegal states unrepresentable: Use the type system to prevent invalid data
- Fail fast: Catch errors at compile time, or as close to the source as possible
- Defense in depth: Application types + database constraints + monitoring
Conclusion
In fintech, the question isn't whether to use typed languages—it's how strictly to enforce types throughout the stack. The combination of:
- TypeScript (with strict mode) + Decimal.js on the frontend
- Protocol Buffers for serialization
- C#, Rust, or F# on the backend
- PostgreSQL with strict schemas
...creates a system where entire categories of bugs simply cannot exist in production. The slight decrease in iteration speed during development is insignificant compared to the cost of a single production money-handling bug.
Type safety isn't about being pedantic—it's about respecting the trust customers place in financial systems. In an industry where precision is paramount, strong typing isn't optional; it's the foundation of reliability.