Back to Blog
Published
·11 min read·Technical

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

javascript
// 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:

typescript
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:

json
{
  "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:

javascript
// The classic JavaScript nightmare
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004

Enter Decimal.js:

typescript
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 any escape hatch)
  • Pattern matching for exhaustive case handling
  • Compiles to readable JavaScript/TypeScript
  • Immutability by default
rescript
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

json
{
  "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

protobuf
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:

  1. Type safety across services: Generate TypeScript/C#/Go clients from same .proto file
  2. Backward compatibility: Built-in versioning via field numbers
  3. Smaller payloads: Binary format ~3-10x smaller than JSON
  4. Faster parsing: 20-100x faster than JSON in benchmarks
  5. Schema enforcement: Invalid messages rejected at parse time

TypeScript usage:

typescript
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:

csharp
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 decimal type: 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:

rust
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_decimal crate: Equivalent to C#'s decimal type

Alternative: F#

For teams preferring functional paradigms:

fsharp
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

  1. ACID compliance: True transactional integrity
  2. NUMERIC type: Arbitrary precision decimal arithmetic
  3. Strong typing: Column constraints enforced at DB level
  4. JSON/JSONB support: Flexibility when needed, with schema validation
  5. Mature replication: Battle-tested for high availability

Schema Design for Type Safety

sql
-- 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:

csharp
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:

sql
-- 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

Frontend
(React/Vue)
TypeScript + Decimal.js
- Strict type checking
- Money value objects
- Exhaustive status matching
Protobuf
API Layer
(Backend)
C# / Rust / F#
- Decimal type for money
- Discriminated unions for status
- Null safety
Npgsql / sqlx
PostgreSQL
(Database)
- NUMERIC columns
- ENUM types
- CHECK constraints
- Foreign keys

Key principles:

  1. Parse, don't validate: Convert untyped input (JSON, form data) to strongly-typed objects at the boundary
  2. Make illegal states unrepresentable: Use the type system to prevent invalid data
  3. Fail fast: Catch errors at compile time, or as close to the source as possible
  4. 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.


Further Reading