Skip to content

Credit Card Validator — Swift source

Validate credit card numbers with the Luhn checksum and detect the issuing brand (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay, Maestro). 100% client-side - nothing is transmitted.

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

// credit-card-validator — Luhn checksum + brand detection for card numbers.
//
// Language: Swift (5.9, standard library only — native Regex literals, Swift 5.7+)
// Source:   CosmoDev polyglot showcase port of the Credit Card Validator tool,
//           ported from src/lib/creditCard.ts (the canonical TypeScript lib) and
//           held in lock-step with cli/credit-card-validator/credit-card-validator.go.
// License:  display source — part of CosmoDev's polyglot tool pages.
//
// Pure credit-card validation with no I/O beyond the demo at the bottom:
// sanitize to digits, run the Luhn checksum, detect the issuing brand by
// prefix + length, and group the number for display. Never crashes on
// malformed input; mirrors the TypeScript contract.
//
// The brand patterns use native Regex literals with prefixMatch(of:), which
// anchors at the start — mirroring the ^-anchored TS/Python patterns. They
// use the extended `#/.../#` delimiters: bare `/.../` literals parse
// ambiguously after an argument label. Each rule stores a closure so patterns
// with different capture shapes share one table type. Run as a script with
// `swift swift.swift` (top-level code needs a main.swift-style entry file
// under `swift build`).

/// Full validation result. `brand` is nil when the issuer is unknown
/// (the Swift equivalent of TypeScript's `string | null`).
struct CardInfo: Equatable {
    var input: String
    var sanitized: String   // digits only
    var valid: Bool         // digits present, sane length, and Luhn passes
    var luhnValid: Bool     // Luhn checksum passes (requires >= 12 digits)
    var brand: String?      // issuing brand, or nil when unknown
    var formatted: String   // grouped per brand spacing rules
}

/// Luhn (mod-10) checksum. Empty or non-digit input returns false.
func luhnCheck(_ digits: String) -> Bool {
    // Empty or any non-ASCII-digit input is invalid — matching /^[0-9]*$/ semantics.
    guard !digits.isEmpty, digits.allSatisfy({ ("0"..."9").contains($0) }) else {
        return false
    }
    var total = 0
    var shouldDouble = false
    // Walk right-to-left; double every second digit and collapse >9 to its digit-sum
    // (e.g. 7*2=14 -> 14-9=5, identical to summing 1+4).
    for ch in digits.reversed() {
        var d = ch.wholeNumberValue ?? 0 // allSatisfy above guarantees a digit
        if shouldDouble {
            d *= 2
            if d > 9 {
                d -= 9
            }
        }
        total += d
        shouldDouble.toggle()
    }
    return total % 10 == 0
}

/// Brand detection rule: name, prefix test, allowed lengths.
/// The prefix test is a closure because Regex literals with capture groups
/// produce different generic outputs — a closure erases that difference.
private struct BrandRule {
    let name: String
    let lengths: Set<Int>
    let matchesPrefix: (String) -> Bool

    init(name: String, lengths: [Int], matchesPrefix: @escaping (String) -> Bool) {
        self.name = name
        self.lengths = Set(lengths)
        self.matchesPrefix = matchesPrefix
    }
}

// Brand detection table. ORDER MATTERS: the first prefix+length match wins.
// Maestro is last because its broad "6" / 5x ranges overlap narrower issuers.
private let brandRules: [BrandRule] = [
    BrandRule(name: "Visa", lengths: [13, 16, 19]) { $0.prefixMatch(of: #/^4/#) != nil },
    BrandRule(name: "Mastercard", lengths: [16]) {
        $0.prefixMatch(of: #/^(5[1-5]|2(2[2-9]|[3-6][0-9]|7[01]|720))/#) != nil
    },
    BrandRule(name: "American Express", lengths: [15]) { $0.prefixMatch(of: #/^3[47]/#) != nil },
    BrandRule(name: "Discover", lengths: [16, 19]) {
        $0.prefixMatch(of: #/^(6011|65|64[4-9]|622(12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9([01][0-9]|2[0-5])))/#) != nil
    },
    BrandRule(name: "Diners Club", lengths: [14, 16]) {
        $0.prefixMatch(of: #/^(30[0-5]|3095|36|38|39|54|55)/#) != nil
    },
    BrandRule(name: "JCB", lengths: [16, 17, 18, 19]) {
        $0.prefixMatch(of: #/^35(2[89]|[3-8][0-9])/#) != nil
    },
    BrandRule(name: "UnionPay", lengths: [16, 17, 18, 19]) { $0.prefixMatch(of: #/^62/#) != nil },
    BrandRule(name: "Maestro", lengths: Array(12...19)) {
        $0.prefixMatch(of: #/^(50|56|57|58|6)/#) != nil
    },
]

/// Detect the card brand by prefix + length. Returns nil when unknown.
func detectBrand(_ digits: String) -> String? {
    guard !digits.isEmpty else { return nil }
    for rule in brandRules where rule.matchesPrefix(digits) && rule.lengths.contains(digits.count) {
        return rule.name
    }
    return nil
}

/// Slice digits into runs of the given widths, joining with spaces.
private func group(_ digits: String, _ widths: [Int]) -> String {
    let chars = Array(digits)
    let n = chars.count
    var parts: [String] = []
    var i = 0
    for w in widths {
        if i >= n { break }
        parts.append(String(chars[i..<min(i + w, n)]))
        i += w
    }
    // Any trailing remainder (e.g. a partially typed number) is appended as-is.
    if i < n {
        parts.append(String(chars[i...]))
    }
    return parts.joined(separator: " ")
}

/// Group the number per brand spacing rules.
func formatCard(_ digits: String, _ brand: String?) -> String {
    if digits.isEmpty { return "" }
    // Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
    if brand == "American Express" {
        return group(digits, [4, 6, 5])
    }
    if brand == "Diners Club" && digits.count == 14 {
        return group(digits, [4, 6, 4])
    }
    // Default: groups of four separated by spaces, no trailing space
    // (equivalent to the TS regex `(.{4})(?=.)` replacement).
    var out = ""
    for (i, ch) in digits.enumerated() {
        if i > 0 && i % 4 == 0 {
            out.append(" ")
        }
        out.append(ch)
    }
    return out
}

/// Keep ASCII digit characters only; multibyte UTF-8 scalars never equal "0"..."9".
private func sanitizeDigits(_ input: String) -> String {
    input.filter { ("0"..."9").contains($0) }
}

/// Validate a card number. Always returns a CardInfo; never crashes.
func validateCard(_ input: String) -> CardInfo {
    let sanitized = sanitizeDigits(input)
    let brand = detectBrand(sanitized)
    // Luhn is only meaningful once we have a plausible card length (>= 12 digits).
    let luhnValid = sanitized.count >= 12 && luhnCheck(sanitized)
    return CardInfo(
        input: input,
        sanitized: sanitized,
        valid: luhnValid,
        luhnValid: luhnValid,
        brand: brand,
        formatted: formatCard(sanitized, brand)
    )
}

// Tiny showcase: validate a well-known test number (Visa).
let info = validateCard("4111 1111 1111 1111")
print("brand=\(info.brand ?? "None") valid=\(info.valid) formatted=\"\(info.formatted)\"")

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 →