Functional Weave
Code in TypeScript

validation.iban@1.0.0

impl/typescript.ts

4,024 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { IBAN_LENGTHS } from "./validation_iban_data.ts";  ← this capability’s own data, compiled from data/iban-lengths.json into the same file by fune build

/** ISO 13616 allows 34 characters at most; Norway's 15 is the shortest issued. */
const MAX_IBAN = 34;
const MIN_IBAN = 15;

function isDigit(ch: string): boolean {
  return ch >= "0" && ch <= "9";
}

function isUpperLetter(ch: string): boolean {
  return ch >= "A" && ch <= "Z";
}

/**
 * Uppercase ASCII only: `toUpperCase()` is Unicode-aware and would disagree
 * with the Rust sibling on inputs like "ß". Nothing in an IBAN is non-ASCII.
 */
function compact(value: string): string {
  let out = "";
  for (const ch of value) {
    // Only the ASCII space is stripped. IBANs are printed in groups of four
    // and pasted that way; hyphens and other punctuation are not a printing
    // convention, they are a sign the value came from somewhere unexpected.
    if (ch === " ") continue;
    out += ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 32) : ch;
  }
  return out;
}

/** The registered IBAN length for a country, or -1 if the country has none. */
export function ibanLength(country: string): number {
  const code = compact(country);
  for (const row of IBAN_LENGTHS) {
    if (row.country === code) return row.length;
  }
  return -1;
}

/**
 * Is this a structurally valid IBAN?
 *
 * Two checks, both necessary. The ISO 13616 mod-97-10 checksum catches
 * mistyped and transposed characters, but on its own it would accept a
 * correctly-checksummed string of any length; the country's registered length
 * is what catches a truncated or padded account number that still happens to
 * check out.
 *
 * Neither check proves the account exists. Only the bank can say that, and
 * only a payment (or a confirmation-of-payee service) proves it belongs to
 * the person you think it does.
 */
export function isIban(value: string): boolean {
  if (typeof value !== "string") return false;

  const iban = compact(value);
  if (iban.length < MIN_IBAN || iban.length > MAX_IBAN) return false;

  // Positions 1-2 are the country, 3-4 the check digits. Testing this before
  // the table lookup means a lower-case or punctuated value fails here rather
  // than being reported as an unknown country.
  if (!isUpperLetter(iban[0]) || !isUpperLetter(iban[1])) return false;
  if (!isDigit(iban[2]) || !isDigit(iban[3])) return false;

  const expected = ibanLength(iban.slice(0, 2));
  if (expected < 0 || iban.length !== expected) return false;

  for (let i = 4; i < iban.length; i++) {
    const ch = iban[i];
    if (!isDigit(ch) && !isUpperLetter(ch)) return false;
  }

  return mod97(iban) === 1;
}

/**
 * ISO 13616 mod-97-10: move the first four characters to the end, replace
 * each letter with its position in the alphabet plus 9 (A=10 ... Z=35), and
 * take the whole thing modulo 97.
 *
 * The expansion of a 34-character IBAN is up to 68 digits, far past any
 * 64-bit integer, so the remainder is carried forward one character at a
 * time. A letter contributes two digits, so it multiplies the running
 * remainder by 100; the largest intermediate is 96 * 100 + 35 = 9635, which
 * is why this is safe in Rust's i64 and in JavaScript's doubles alike.
 */
function mod97(iban: string): number {
  let remainder = 0;
  for (let i = 0; i < iban.length; i++) {
    // Rotation without building a second string: read from position 4
    // onwards, then wrap round to the first four characters.
    const ch = iban[(i + 4) % iban.length];
    if (isDigit(ch)) {
      remainder = (remainder * 10 + (ch.charCodeAt(0) - 48)) % 97;
    } else {
      remainder = (remainder * 100 + (ch.charCodeAt(0) - 55)) % 97;
    }
  }
  return remainder;
}

/**
 * The IBAN in its printed form, groups of four separated by single spaces,
 * or null if it does not validate.
 */
export function formatIban(value: string): string | null {
  if (!isIban(value)) return null;
  const iban = compact(value);
  const groups: string[] = [];
  for (let i = 0; i < iban.length; i += 4) groups.push(iban.slice(i, i + 4));
  return groups.join(" ");
}