DEV Community

Cover image for Stop reinventing financial validation in TypeScript
Oluwatosin Adelaja
Oluwatosin Adelaja

Posted on

Stop reinventing financial validation in TypeScript

If you've worked on a payment product, a banking app, or anything involving money, you've written this code before.

An IBAN validator you copied from Stack Overflow. A Luhn check for card numbers someone found in a gist. A currency formatter that works in en-US but breaks for de-DE. A sort code regex that someone on the team "maintains." A loan calculator buried in a utility file no one touches.

Every fintech team builds this stuff. Then rebuilds it. Then inherits the third version and tries to figure out why it exists.

I built finprim to fix this.


What finprim is

finprim is a zero-dependency TypeScript library that handles the financial primitives every app needs:

  • IBAN validation (80+ countries) and formatting
  • BIC/SWIFT validation
  • UK sort code and account number validation
  • Card number validation (Luhn algorithm + network detection)
  • EU VAT number validation
  • US ABA routing number validation
  • Loan/EMI calculations with full amortization schedules
  • Multi-locale currency formatting and parsing

One library. Consistent API. Fully typed.

npm install finprim
Enter fullscreen mode Exit fullscreen mode
import { validateIBAN, validateCardNumber, formatCurrency } from 'finprim'

validateIBAN('GB29NWBK60161331926819')
// { valid: true, value: 'GB29NWBK60161331926819', formatted: 'GB29 NWBK 6016 1331 9268 19', countryCode: 'GB' }

validateCardNumber('4532015112830366')
// { valid: true, formatted: '4532 0151 1283 0366', network: 'Visa', last4: '0366' }

formatCurrency(1000.5, 'EUR', 'de-DE')
// '1.000,50 €'
Enter fullscreen mode Exit fullscreen mode

No config. No setup. Just import and use.


The TypeScript part you'll actually care about

Here's where it gets good. Every validator returns a discriminated union:

type ValidationResult<T> =
  | { valid: true; value: T; formatted: string }
  | { valid: false; error: string }
Enter fullscreen mode Exit fullscreen mode

And the values are branded types — not just strings:

type IBAN = string & { __brand: 'IBAN' }
type SortCode = string & { __brand: 'SortCode' }
Enter fullscreen mode Exit fullscreen mode

This means TypeScript will catch you if you try to pass an unvalidated string where a validated IBAN is expected:

function processPayment(iban: IBAN, amount: number) {
  // ...
}

// This won't compile — userInput is just a string
processPayment(userInput, 100) // Error!

// You must validate first
const result = validateIBAN(userInput)
if (result.valid) {
  processPayment(result.value, 100) // result.value is typed as IBAN
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of thing that prevents entire categories of production bugs.


Works with your existing stack

The core is zero-dependency. But if you're using Zod, React, or NestJS, there are optional integrations:

Zod

import { z } from 'zod'
import { ibanSchema, sortCodeSchema, currencySchema } from 'finprim/zod'

const PaymentSchema = z.object({
  iban: ibanSchema,
  sortCode: sortCodeSchema,
  amount: z.number().positive(),
  currency: currencySchema,
})
Enter fullscreen mode Exit fullscreen mode

React hooks for form inputs

import { useIBANInput, useCardNumberInput } from 'finprim/react'

function PaymentForm() {
  const iban = useIBANInput()
  const card = useCardNumberInput()

  return (
    <form>
      <input
        value={iban.value}
        onChange={iban.onChange}
        aria-invalid={iban.valid === false}
        placeholder="GB29 NWBK 6016 1331 9268 19"
      />
      <input
        value={card.formatted}
        onChange={card.onChange}
        aria-invalid={card.valid === false}
        placeholder="4532 0151 1283 0366"
      />
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

The hook formats as you type and validates in real time.

NestJS pipes

import { IbanValidationPipe } from 'finprim/nest'

@Get('account/:iban')
findByIban(@Param('iban', IbanValidationPipe) iban: string) {
  return this.service.findByIban(iban)
}
Enter fullscreen mode Exit fullscreen mode

Loan calculations

Financial apps often need EMI calculations. This one's baked in:

import { calculateEMI, getLoanSchedule } from 'finprim'

// Monthly payment on a £100,000 loan at 10% APR over 12 months
const monthly = calculateEMI(100_000, 10, 12)

// Full amortization schedule
const schedule = getLoanSchedule(100_000, 10, 12)
// [{ month: 1, payment: 8791.59, principal: 8124.92, interest: 833.33, balance: 91875.08 }, ...]
Enter fullscreen mode Exit fullscreen mode

Production-grade by default

A few things that matter in real apps:

  • All string validators reject inputs longer than 256 chars (no unbounded input processing)
  • Numeric helpers validate bounds (no Infinity, no NaN)
  • The library never logs or persists input data
  • Format helpers are safe to call without validation (useful for display-only flows)

Try it

npm install finprim
Enter fullscreen mode Exit fullscreen mode

If it saves you time, a star on GitHub goes a long way. And if you find an edge case or want to add a validator, PRs are open.

Top comments (1)

Collapse
 
jon_at_backboardio profile image
Jonathan Murray

This is a real problem - everyone writes their own version of 'is this a valid currency amount' and they all have slightly different edge cases. The Dinero.js approach is solid but I've seen teams get burned by library version drift when the library's assumptions about precision don't match the payment processor's. What's your take on handling currency conversion in the same lib vs keeping it separate?