Skip to content

IBAN Validator — Zig source

Validate International Bank Account Numbers (IBAN) with the mod-97 checksum, verify the country-specific length, and format the result. 100% client-side, no network.

This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! iban-validator — pure IBAN validation logic (ISO 13616 mod-97 checksum).
//!
//! Language: Zig 0.13 (standard library only)
//! Source:   CosmoDev polyglot showcase port of the iban-validator tool,
//!           ported from src/lib/iban.ts (the canonical TypeScript
//!           implementation).
//! License:  display source — part of CosmoDev's polyglot tool pages
//!
//! Normalizes the input (uppercase, strip whitespace/dashes), checks the
//! structure (country code + 2 check digits + 1-30 BBAN chars), verifies
//! the per-country length, then runs the ISO 13616 mod-97 checksum: move
//! the first 4 chars to the end, map A=10..Z=35, and confirm the
//! resulting integer is congruent to 1 mod 97. The remainder is folded
//! one decimal digit at a time (it stays < 97), so no allocator or
//! arbitrary-precision arithmetic is required.

const std = @import("std");

const CountryLength = struct { cc: []const u8, len: u8 };

/// Per-country IBAN lengths (ISO 13616) — a representative subset.
const IBAN_LENGTHS = [_]CountryLength{
    .{ .cc = "AL", .len = 28 }, .{ .cc = "AD", .len = 24 }, .{ .cc = "AT", .len = 20 },
    .{ .cc = "AZ", .len = 28 }, .{ .cc = "BH", .len = 22 }, .{ .cc = "BY", .len = 28 },
    .{ .cc = "BE", .len = 16 }, .{ .cc = "BA", .len = 20 }, .{ .cc = "BR", .len = 29 },
    .{ .cc = "BG", .len = 22 }, .{ .cc = "CR", .len = 22 }, .{ .cc = "HR", .len = 21 },
    .{ .cc = "CY", .len = 28 }, .{ .cc = "CZ", .len = 24 }, .{ .cc = "DK", .len = 18 },
    .{ .cc = "DO", .len = 28 }, .{ .cc = "EE", .len = 20 }, .{ .cc = "FO", .len = 18 },
    .{ .cc = "FI", .len = 18 }, .{ .cc = "FR", .len = 27 }, .{ .cc = "GE", .len = 22 },
    .{ .cc = "DE", .len = 22 }, .{ .cc = "GI", .len = 23 }, .{ .cc = "GR", .len = 27 },
    .{ .cc = "GL", .len = 18 }, .{ .cc = "GT", .len = 28 }, .{ .cc = "HU", .len = 28 },
    .{ .cc = "IS", .len = 26 }, .{ .cc = "IE", .len = 22 }, .{ .cc = "IL", .len = 23 },
    .{ .cc = "IT", .len = 27 }, .{ .cc = "JO", .len = 30 }, .{ .cc = "KZ", .len = 20 },
    .{ .cc = "XK", .len = 20 }, .{ .cc = "KW", .len = 30 }, .{ .cc = "LV", .len = 21 },
    .{ .cc = "LB", .len = 28 }, .{ .cc = "LI", .len = 21 }, .{ .cc = "LT", .len = 20 },
    .{ .cc = "LU", .len = 20 }, .{ .cc = "MK", .len = 19 }, .{ .cc = "MT", .len = 31 },
    .{ .cc = "MR", .len = 27 }, .{ .cc = "MU", .len = 30 }, .{ .cc = "MC", .len = 27 },
    .{ .cc = "MD", .len = 24 }, .{ .cc = "ME", .len = 22 }, .{ .cc = "NL", .len = 18 },
    .{ .cc = "NO", .len = 15 }, .{ .cc = "PK", .len = 24 }, .{ .cc = "PS", .len = 29 },
    .{ .cc = "PL", .len = 28 }, .{ .cc = "PT", .len = 25 }, .{ .cc = "QA", .len = 29 },
    .{ .cc = "RO", .len = 24 }, .{ .cc = "LC", .len = 32 }, .{ .cc = "SM", .len = 27 },
    .{ .cc = "ST", .len = 25 }, .{ .cc = "SA", .len = 24 }, .{ .cc = "RS", .len = 22 },
    .{ .cc = "SC", .len = 31 }, .{ .cc = "SK", .len = 24 }, .{ .cc = "SI", .len = 19 },
    .{ .cc = "SG", .len = 19 }, .{ .cc = "ES", .len = 24 }, .{ .cc = "SE", .len = 24 },
    .{ .cc = "CH", .len = 21 }, .{ .cc = "TL", .len = 23 }, .{ .cc = "TN", .len = 24 },
    .{ .cc = "TR", .len = 26 }, .{ .cc = "UA", .len = 29 }, .{ .cc = "AE", .len = 23 },
    .{ .cc = "GB", .len = 22 }, .{ .cc = "VG", .len = 24 },
};

fn ibanLengthFor(cc: []const u8) ?u8 {
    for (IBAN_LENGTHS) |e| {
        if (std.mem.eql(u8, e.cc, cc)) return e.len;
    }
    return null;
}

/// Result of validation — optional fields mirror the TS nullable fields.
/// (`error_message`, not `error`: `error` is a Zig keyword.)
pub const IbanResult = struct {
    // Buffers the string fields below point into; keeping them in the
    // result and filling it through a pointer keeps the whole validation
    // allocation-free.
    cleaned_buf: [64]u8 = undefined,
    formatted_buf: [80]u8 = undefined,
    country_buf: [2]u8 = undefined,
    error_buf: [64]u8 = undefined,

    input: []const u8 = "",
    cleaned: []const u8 = "",
    country_code: ?[]const u8 = null, // null when the head is not 2 letters
    valid: bool = false,
    checksum_ok: bool = false,
    length_ok: bool = false,
    expected_length: ?u8 = null, // null when the country is unknown
    formatted: []const u8 = "",
    error_message: ?[]const u8 = null, // null when valid
};

/// ISO 13616 mod-97 checksum over a CLEANED iban (uppercase, no spaces).
pub fn mod97Check(cleaned: []const u8) bool {
    if (cleaned.len < 4) return false;
    // Rearranged = cleaned[4..] ++ cleaned[..4] (country + check moved to
    // the end); index (i + 4) % len walks that rotation in place.
    var rem: u32 = 0;
    var i: usize = 0;
    while (i < cleaned.len) : (i += 1) {
        const ch = cleaned[(i + 4) % cleaned.len];
        var value: u32 = undefined;
        var digits: u8 = undefined;
        if (std.ascii.isDigit(ch)) {
            value = ch - '0';
            digits = 1;
        } else if (std.ascii.isUpper(ch)) {
            value = ch - 'A' + 10; // A=10 .. Z=35
            digits = 2;
        } else {
            return false; // invalid character
        }
        // Fold one decimal digit at a time; rem stays < 97.
        if (digits == 2) rem = (rem * 10 + value / 10) % 97;
        rem = (rem * 10 + value % 10) % 97;
    }
    return rem == 1;
}

/// Insert a space every 4 characters (last group may be short) — the
/// hand-rolled equivalent of the TS regex /(.{4})(?=.)/g + trim.
fn formatGroups(cleaned: []const u8, out: []u8) []const u8 {
    var o: usize = 0;
    for (cleaned, 0..) |ch, i| {
        if (i > 0 and i % 4 == 0 and o < out.len) {
            out[o] = ' ';
            o += 1;
        }
        if (o < out.len) {
            out[o] = ch;
            o += 1;
        }
    }
    return out[0..o];
}

/// Validate an IBAN into `out`. Never fails — all failures are reported
/// via `error_message`. String results are slices into `out`'s own
/// buffers, so `out` must outlive the result.
pub fn validateIban(input: []const u8, out: *IbanResult) void {
    out.* = .{ .input = input };

    // Uppercase and strip whitespace/dashes.
    var n: usize = 0;
    for (input) |raw| {
        const ch = std.ascii.toUpper(raw);
        switch (ch) {
            ' ', '-', '\t', '\n', '\r' => {}, // strip separators
            else => {
                if (n < out.cleaned_buf.len) {
                    out.cleaned_buf[n] = ch;
                    n += 1;
                }
            },
        }
    }
    const cleaned = out.cleaned_buf[0..n];
    out.cleaned = cleaned;

    if (n >= 2 and std.ascii.isUpper(cleaned[0]) and std.ascii.isUpper(cleaned[1])) {
        out.country_buf[0] = cleaned[0];
        out.country_buf[1] = cleaned[1];
        out.country_code = out.country_buf[0..2];
        out.expected_length = ibanLengthFor(out.country_buf[0..2]);
    }
    out.formatted = formatGroups(cleaned, &out.formatted_buf);

    // Structure: 2 letters, 2 digits, then 1..30 alnum, total len 5..34.
    var struct_ok = n >= 5 and n <= 34
        and std.ascii.isUpper(cleaned[0]) and std.ascii.isUpper(cleaned[1])
        and std.ascii.isDigit(cleaned[2]) and std.ascii.isDigit(cleaned[3]);
    if (struct_ok) {
        for (cleaned[4..]) |ch| {
            if (!std.ascii.isUpper(ch) and !std.ascii.isDigit(ch)) {
                struct_ok = false;
                break;
            }
        }
    }
    if (!struct_ok) {
        out.error_message = "Invalid IBAN format.";
        return;
    }

    out.checksum_ok = mod97Check(cleaned);
    out.length_ok = if (out.expected_length) |want| n == want else true;
    if (!out.length_ok) {
        out.error_message = std.fmt.bufPrint(
            &out.error_buf,
            "Length should be {d} for {s}.",
            .{ out.expected_length.?, out.country_code.? },
        ) catch "Length mismatch.";
        return;
    }
    if (!out.checksum_ok) {
        out.error_message = "Checksum failed.";
        return;
    }
    out.valid = true;
}

pub fn main() !void {
    // GB82 WEST 1234 5698 7654 32 — a known-good reference IBAN.
    var result: IbanResult = .{};
    validateIban("GB82WEST12345698765432", &result);
    var buf: [128]u8 = undefined;
    const line = try std.fmt.bufPrint(&buf, "valid={} error={s}\n", .{
        result.valid,
        result.error_message orelse "(none)",
    });
    try std.io.getStdOut().writeAll(line);
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →