Skip to content

IBAN Validator — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package ibanvalidator is the Go twin of CosmoDev's src/lib/iban.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// iban-validator_test.go share vectors with src/lib/iban.test.ts so the two
// implementations are held to the same contract.
//
// The algorithm mirrors the TS lib exactly: normalise (uppercase, strip
// whitespace/dashes) → read the country code → look up the per-country length →
// verify the structure (2 letters, 2 check digits, 1–30 alphanumerics) → check
// the length → run the ISO 13616 mod-97 checksum (first 4 chars rotated to the
// end, letters → A=10…Z=35, running remainder mod 97 must end at 1).
package ibanvalidator

import (
	"fmt"
	"regexp"
	"strings"
)

// IbanInfo is the structured verdict returned by ValidateIban. It is the Go twin
// of IbanInfo in src/lib/iban.ts. Nullable TS fields use the Go zero value as
// the "absent" sentinel, mirroring TS null (the same convention as
// emailvalidator.EmailResult):
//   - CountryCode: "" means null (a real code is always 2 uppercase letters).
//   - ExpectedLength: 0 means null (real ISO 13616 lengths are ≥ 15).
//   - Error: "" means null.
type IbanInfo struct {
	Input          string
	Cleaned        string
	CountryCode    string
	Valid          bool
	ChecksumOK     bool
	LengthOK       bool
	ExpectedLength int
	Formatted      string
	Error          string
}

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

var (
	// countryCodeRe matches a leading pair of uppercase letters — the country
	// code. Mirrors /^[A-Z]{2}/ in the TS lib.
	countryCodeRe = regexp.MustCompile(`^[A-Z]{2}`)
	// formatRe is the structural check: 2 letters, 2 check digits, then 1–30
	// alphanumerics. Mirrors /^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$/ in the TS lib.
	formatRe = regexp.MustCompile(`^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$`)
	// stripRe matches a single whitespace rune or dash, removed during
	// normalisation. Mirrors /[\s-]/g in the TS lib.
	stripRe = regexp.MustCompile(`[\s-]`)
)

// formatGroups inserts a space after every run of 4 characters, mirroring the
// TS lib's cleaned.replace(/(.{4})(?=.)/g, '$1 ').trim(). Go's RE2 has no
// lookahead, so the grouping is built directly; because a space is only ever
// inserted between groups, the result is already trimmed (TrimSpace is applied
// defensively to match the TS .trim() exactly).
func formatGroups(s string) string {
	if s == "" {
		return ""
	}
	runes := []rune(s)
	var b strings.Builder
	for i, r := range runes {
		if i > 0 && i%4 == 0 {
			b.WriteByte(' ')
		}
		b.WriteRune(r)
	}
	return strings.TrimSpace(b.String())
}

// Mod97Check runs the ISO 13616 mod-97 checksum over a CLEANED iban (uppercase,
// no spaces). It is the Go twin of mod97Check() in src/lib/iban.ts. The first
// 4 characters (country + check digits) are rotated to the end; letters become
// A=10…Z=35; the running remainder mod 97 must end at 1. Any character outside
// [0-9A-Z] (including lowercase or punctuation) makes it return false. It never
// panics: the per-digit modular reduction keeps the accumulator well below any
// integer limit, so no big.Int is needed (the TS lib's BigInt is cautionary).
func Mod97Check(cleaned string) bool {
	var rearranged string
	if len(cleaned) >= 4 {
		rearranged = cleaned[4:] + cleaned[:4]
	} else {
		// JS slice(4) === "" and slice(0,4) === cleaned when fewer than 4 chars,
		// so rearranged is just the original string.
		rearranged = cleaned
	}

	numeric := make([]byte, 0, len(rearranged)*2)
	for _, r := range rearranged {
		switch {
		case r >= '0' && r <= '9':
			numeric = append(numeric, byte(r))
		case r >= 'A' && r <= 'Z':
			v := int(r - 55) // A=10 .. Z=35 (always two digits)
			numeric = append(numeric, byte('0'+v/10), byte('0'+v%10))
		default:
			return false // invalid character (mirrors the TS else branch)
		}
	}

	rem := 0
	for _, ch := range numeric {
		rem = (rem*10 + int(ch-'0')) % 97
	}
	return rem == 1
}

// ValidateIban validates an IBAN and returns the full verdict. It is the Go twin
// of validateIban() in src/lib/iban.ts and must agree with it on every shared
// vector. It never panics: the TS lib's nullish-input handling (input ?? "")
// maps to the empty-string path here, since Go has no null strings — ValidateIban
// ("") produces the same IbanInfo the TS lib yields for null/undefined.
func ValidateIban(input string) IbanInfo {
	cleaned := stripRe.ReplaceAllString(strings.ToUpper(input), "")

	cc := ""
	if countryCodeRe.MatchString(cleaned) {
		cc = cleaned[:2] // ^[A-Z]{2} guarantees len(cleaned) >= 2
	}

	var expected int
	if cc != "" {
		if v, ok := IbanLengths[cc]; ok {
			expected = v
		}
	}

	// base mirrors the TS `base` object: all verdict flags false, no error.
	base := IbanInfo{
		Input:          input,
		Cleaned:        cleaned,
		CountryCode:    cc,
		ExpectedLength: expected,
		Formatted:      formatGroups(cleaned),
	}

	if !formatRe.MatchString(cleaned) {
		base.Error = "Invalid IBAN format."
		return base
	}

	lengthOk := expected == 0 || len(cleaned) == expected // expected===null ? true : …
	checksumOk := Mod97Check(cleaned)

	if !lengthOk {
		base.ChecksumOK = checksumOk
		base.Error = fmt.Sprintf("Length should be %d for %s.", expected, cc)
		return base
	}
	if !checksumOk {
		base.LengthOK = true
		base.Error = "Checksum failed."
		return base
	}
	base.LengthOK = true
	base.ChecksumOK = true
	base.Valid = true
	return base
}

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 →