DEV Community

Cover image for I Got Tired of Copy-Pasting IBAN Validation Code Across Every Project — So I Built Two Open Source Packages
Oyinlola Abolarin
Oyinlola Abolarin

Posted on

I Got Tired of Copy-Pasting IBAN Validation Code Across Every Project — So I Built Two Open Source Packages

Announcing iban_validator for Flutter/Dart and iban-validate for TypeScript/JavaScript


There is a piece of code I have written at least four times in the last two years.

It strips spaces from a string, converts letters to numbers, rearranges some characters, and computes a modular arithmetic checksum. It validates IBAN numbers, the international bank account format used for cross-border payments, and it lives in a different file in a different language in every project I have ever worked on that needed it.

Flutter app? I write it in Dart. React dashboard? I write it in TypeScript. New project six months later? I find the old file, copy it across, tweak it slightly, and inevitably introduce a subtle bug that only shows up when a user from Norway tries to make a payment because Norwegian IBANs are 15 characters and I hardcoded 16 somewhere.

I got tired of it. So I built it properly.

Today, I am releasing two open source packages:

Both support 116 countries, implement the official ISO 7064 MOD-97–10 validation algorithm, return typed errors with human-readable messages, and have zero production dependencies.

This article explains why I built them, how the algorithm works, what makes them different from existing solutions, and how to use them in your project.


What is an IBAN and why does validation matter?

IBAN stands for International Bank Account Number. If you have sent money internationally, especially within Europe, or to the Middle East, you have used one. They look like this:

DE89 3704 0044 0532 0130 00   ← Germany
GB29 NWBK 6016 1331 9268 19   ← United Kingdom
SA44 2000 0001 2345 6789 1234  ← Saudi Arabia
CI93 CI00 8011 1301 1342 9120 0589  ← Ivory Coast
Enter fullscreen mode Exit fullscreen mode

The first two characters are the ISO country code. The next two are check digits. The rest is the domestic bank account number in a country-specific format. Every country that uses IBAN has a fixed, registered length. Germany is always 22 characters, Norway is always 15, and Malta is always 31.

What makes IBAN genuinely interesting from an engineering perspective is that the check digits are mathematically derived from the rest of the number. This means you can detect errors, transposed digits, missing characters, typos, before you ever attempt to send the payment.

In fintech, that matters. A failed international transfer is not just an inconvenience. It can mean frozen funds, customer support tickets, refund delays, and, in some cases, fees charged by intermediate banks for routing a payment that bounced. Catching a bad IBAN at the moment a user types it, rather than after submission, is genuinely valuable.


How the validation algorithm works

The algorithm is defined in ISO 7064 and has five steps. I want to walk through it properly because understanding it will make the code make sense.

Step 1 — Clean the input

Strip all spaces and convert to uppercase. Users type IBANs in all sorts of formats with spaces, without spaces, in lowercase. The algorithm does not care about any of that. Normalise first.

"de89 3704 0044 0532 0130 00"    "DE89370400440532013000"
Enter fullscreen mode Exit fullscreen mode

Step 2 — Validate the country and length

The first two characters identify the country. Every IBAN-supporting country has a fixed, registered length defined by the SWIFT IBAN Registry. Germany’s IBANs are always 22 characters. If you receive a 21-character string starting with DE, it is wrong before you even look at the checksum.

This step alone catches a large proportion of errors.

Step 3 — Rearrange the string

Move the first four characters of the country code plus the two check digits to the end of the string.

"DE89370400440532013000"    "370400440532013000DE89"
Enter fullscreen mode Exit fullscreen mode

Step 4 — Convert letters to numbers

Replace every letter with its numeric equivalent: A=10, B=11, C=12, and so on up to Z=35. This converts the alphanumeric string into a pure digit string.

"370400440532013000DE89"
 "370400440532013000" + "13" + "14" + "89"
 "3704004405320130001314 89"
Enter fullscreen mode Exit fullscreen mode

D becomes 13, E becomes 14, because D is the 4th letter (A=10, B=11, C=12, D=13).

Step 5 — Compute mod 97

Interpret the entire digit string as a large integer and compute its remainder when divided by 97. If the remainder equals exactly 1, the IBAN is valid.

3704004405320130001314 89  mod  97  =  1  ✓
Enter fullscreen mode Exit fullscreen mode

The mathematical reason this works is rooted in number theory; the check digits were specifically chosen during IBAN generation to make this remainder equal 1. Any single-character error or transposition will (in virtually all cases) produce a different remainder.

The integer overflow problem

Here is the engineering challenge: that digit string can be up to 34 characters long, representing a number around 1⁰³⁴. JavaScript’s Number type can only safely represent integers up to about 9×10¹⁵. Dart int handles arbitrary sizes natively on VM platforms, but for portability across web and mobile, you want to avoid relying on that.

The solution is to process the digit string in 7-character chunks, carrying the remainder forward at each step. The result after all chunks is mathematically identical to computing mod 97 on the entire number in one go, and every intermediate value stays well within safe integer range on every platform.

No BigInt. No external dependencies. Just arithmetic.


What was missing from existing solutions

Before building these packages, I looked at what existed. The picture was not great.

European-only coverage. Most IBAN libraries were built by European developers for European use cases. They covered SEPA countries well and quietly ignored the rest of the world. If you needed to validate a Bahraini IBAN (BH, 22 characters) or a Kazakh IBAN (KZ, 20 characters), You were on your own.

Boolean returns. The majority of libraries returned true or false. That is fine for a basic check, but useless for building good user experiences. When validation fails, your UI needs to know why to show the right error message, to highlight the right field, and to give the user actionable guidance.

No metadata. Length constraints, SEPA membership, and sample IBANs were not available. You could not build a country picker that showed only supported countries, or a length hint that updated as the user selected their country.

Abandonment. Several of the most-starred packages had not been updated in two or three years. The IBAN registry is not static; new countries are added, and formats occasionally change.


What I built instead

116 countries

The official SWIFT IBAN Registry has 94 registered countries as of March 2026. I added 22 more, mostly in Africa, that have adopted IBAN-like formats locally but are not yet formally registered. These include the Ivory Coast, Senegal, Mali, Cameroon, Togo, Burkina Faso, Benin, Niger, and others. They are flagged as isExperimental: true in the registry so you can make an informed decision about whether to trust them in production.

The African coverage matters to me personally. A significant amount of fintech development is happening across West and East Africa right now, and developer tooling often lags behind. If you are building a payment product for this market, you need these country codes.

Typed errors

Both packages return a result object with a typed error and a human-readable message.

// Flutter / Dart
final result = IbanValidator.validate('DE99370400440532013000');

if (!result.isValid) {
  print(result.error);
  // IbanValidationError.checksumFailed

  print(result.errorMessage);
  // "The mod-97 checksum failed. The IBAN contains an error."

  print(result.countryInfo?.countryName);
  // "Germany"
}
Enter fullscreen mode Exit fullscreen mode
// TypeScript / JavaScript
const result = validate('DE99370400440532013000');

if (!result.isValid) {
  console.log(result.error);
  // 'checksumFailed'

  console.log(result.errorMessage);
  // 'The mod-97 checksum failed. The IBAN contains an error.'

  console.log(result.countryInfo?.countryName);
  // 'Germany'
}
Enter fullscreen mode Exit fullscreen mode

The possible error values are:

Error Meaning
emptyInput Input was empty
tooShort Fewer than 4 characters
invalidCharacters Contains characters outside A–Z and 0–9
unknownCountry Country code not in the registry
countryMismatch Doesn't match the country constraint you passed
invalidLength Wrong length for the country
checksumFailed The mod-97 check failed, likely a typo

This means you can show specific, actionable error messages rather than a generic "invalid IBAN" that leaves the user guessing.

Country metadata

Both packages expose a full country registry you can query at runtime.

// Dart
final info = IbanValidator.getCountryInfo('CI')!;
info.countryName;    // 'Ivory Coast'
info.ibanLength;     // 28
info.isSepa;         // false
info.isExperimental; // true
info.example;        // 'CI93CI0080111301134291200589'

// Useful for building country pickers, length hints, etc.
final sepaCountries = IbanValidator.getSepaCountries();
final africanCountries = IbanValidator.getExperimentalCountries();
Enter fullscreen mode Exit fullscreen mode
// TypeScript
const info = getCountryInfo('SA')!;
info.countryName; // 'Saudi Arabia'
info.ibanLength;  // 24
info.isSepa;      // false
info.example;     // 'SA4420000001234567891234'
Enter fullscreen mode Exit fullscreen mode

Consistent API across both ecosystems

This was the design constraint I cared about most. If you use both packages, Flutter for mobile, React for web, the mental model should be identical.

Flutter/Dart TypeScript/JS
IbanValidator.isValid(iban) isValid(iban)
IbanValidator.validate(iban) validate(iban)
IbanValidator.getCountryInfo('DE') getCountryInfo('DE')
IbanValidator.getSepaCountries() getSepaCountries()

Same country registry. Same validation logic. Same error vocabulary. Your mobile and web products behave identically, and switching between codebases does not require context-switching in your head.


Using the packages in practice

Flutter

Add to pubspec.yaml:

dependencies:
  iban_validator: ^1.0.0
Enter fullscreen mode Exit fullscreen mode

Basic form validation:

import 'package:iban_validator/iban_validator.dart';

TextFormField(
  decoration: const InputDecoration(labelText: 'IBAN'),
  validator: (value) {
    if (value == null || value.trim().isEmpty) {
      return 'Please enter your IBAN.';
    }
    final result = IbanValidator.validate(value);
    return result.isValid ? null : result.errorMessage;
  },
)
Enter fullscreen mode Exit fullscreen mode

Country-constrained validation (when your app already knows the user's country):

final result = IbanValidator.validate(
  ibanInput,
  countryCca2: selectedCountryCode, // e.g. 'FR', 'AE', 'CI'
);
Enter fullscreen mode Exit fullscreen mode

TypeScript / JavaScript

Install:

npm install iban-validate
# or
yarn add iban-validate
Enter fullscreen mode Exit fullscreen mode

With React Hook Form:

import { isValid } from 'iban-validate';

<input
  {...register('iban', {
    validate: (value) => isValid(value) || 'Please enter a valid IBAN.',
  })}
/>
Enter fullscreen mode Exit fullscreen mode

With Zod:

import { z } from 'zod';
import { isValid } from 'iban-validate';

const schema = z.object({
  iban: z.string().refine(isValid, {
    message: 'Please enter a valid IBAN.',
  }),
});
Enter fullscreen mode Exit fullscreen mode

In a Next.js API route:

import { validate } from 'iban-validate';

export async function POST(req: Request) {
  const { iban } = await req.json();
  const result = validate(iban);

  if (!result.isValid) {
    return Response.json(
      { error: result.error, message: result.errorMessage },
      { status: 422 }
    );
  }

  // Safe to use result.cleanedIban from here
  await processPayment(result.cleanedIban);
}
Enter fullscreen mode Exit fullscreen mode

CommonJS also works:

const { isValid } = require('iban-validate');
console.log(isValid('DE89 3704 0044 0532 0130 00')); // true
Enter fullscreen mode Exit fullscreen mode

What is next

A few things I want to add in upcoming versions:

Format helpers. A formatIban function that takes a raw IBAN string and returns it in the standard human-readable grouped format (DE89 3704 0044 0532 0130 00). Useful for display.

More African countries. The continent is rapidly adopting IBAN and I want to stay ahead of that. Several countries are in the process of formal SWIFT registration.

BBAN extraction. Pull out the domestic bank code and account number from the IBAN for countries where the format is well-defined. This would let you display the sort code from a UK IBAN or the Bankleitzahl from a German one.

If there is something you need that is not there, open an issue on GitHub. PRs are very welcome.


Find the packages

Flutter / Dart
pub.dev → pub.dev/packages/iban_validator
GitHub → github.com/khrisbreezy/iban_validator

TypeScript / JavaScript
npm → npmjs.com/package/iban-validate
GitHub → github.com/khrisbreezy/iban-validation

Both are MIT licensed. If you use them, the best thing you can do is star the GitHub repos and leave a like on pub.dev. It helps other developers find them.

And if you are building something in fintech, especially for African or Middle Eastern markets, I would genuinely love to hear what you are working on.


Top comments (0)