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 →