Skip to content

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

//! credit-card-validator — Luhn checksum + brand detection for card numbers.
//!
//! Language: Zig 0.13 — standard library only (std).
//! 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 main: sanitize to
//! digits, run the Luhn checksum, detect the issuing brand by prefix + length,
//! and group the number for display. Never panics on malformed input (only
//! allocation failure is surfaced, as an error); mirrors the TypeScript contract.
//!
//! Zig's std has no regex module (an ecosystem regex package would be the
//! equivalent), so — mirroring rust.rs — the brand prefixes are matched by
//! hand on the byte slice. All sanitized digits are ASCII, so byte indexing
//! is correct and UTF-8-safe. The `sanitized` and `formatted` fields are
//! owned by the caller's allocator — release them with `freeCardInfo`.

const std = @import("std");

/// Full validation result. `brand` is null when the issuer is unknown
/// (the Zig equivalent of TypeScript's `string | null`).
pub const CardInfo = struct {
    input: []const u8,   // borrowed from the caller
    sanitized: []const u8, // digits only; allocator-owned, freed by freeCardInfo
    valid: bool,           // digits present, sane length, and Luhn passes
    luhn_valid: bool,      // Luhn checksum passes (requires >= 12 digits)
    brand: ?[]const u8,    // issuing brand, or null when unknown
    formatted: []const u8, // allocator-owned, freed by freeCardInfo
};

// ---- Small byte helpers (digits are ASCII, so byte compares are safe). ----

fn isDigitByte(ch: u8) bool {
    return ch >= '0' and ch <= '9';
}

fn inRange(ch: u8, lo: u8, hi: u8) bool {
    return ch >= lo and ch <= hi;
}

fn lenOk(lengths: []const usize, n: usize) bool {
    for (lengths) |l| {
        if (l == n) return true;
    }
    return false;
}

/// Keep ASCII digit bytes only; multibyte UTF-8 bytes are all >= 0x80, so excluded.
/// The returned slice is owned by `alloc`.
fn sanitizeDigits(alloc: std.mem.Allocator, input: []const u8) ![]u8 {
    var out = std.ArrayList(u8).init(alloc);
    defer out.deinit();
    for (input) |ch| {
        if (isDigitByte(ch)) {
            try out.append(ch);
        }
    }
    return try alloc.dupe(u8, out.items);
}

/// Luhn (mod-10) checksum. Empty or non-digit input returns false.
pub fn luhnCheck(digits: []const u8) bool {
    if (digits.len == 0) return false;
    var total: u32 = 0;
    var should_double = false;
    // Walk right-to-left; double every second digit, collapsing 10-18 to its digit-sum.
    var i = digits.len;
    while (i > 0) {
        i -= 1;
        const ch = digits[i];
        if (!isDigitByte(ch)) return false; // non-digit -> invalid, matching /^[0-9]*$/
        var d: u32 = ch - '0';
        if (should_double) {
            d *= 2;
            if (d > 9) {
                d -= 9;
            }
        }
        total += d;
        should_double = !should_double;
    }
    return total % 10 == 0;
}

// ---- Brand prefix matchers (hand-rolled equivalents of the TS regexes). ----

/// Mastercard: 5[1-5], or 2(2[2-9] | [3-6][0-9] | 7[01] | 720).
fn matchesMastercard(b: []const u8) bool {
    if (b.len >= 2 and b[0] == '5' and inRange(b[1], '1', '5')) return true;
    if (b.len >= 2 and b[0] == '2') {
        switch (b[1]) {
            // 22[2-9]
            '2' => {
                if (b.len >= 3 and inRange(b[2], '2', '9')) return true;
            },
            // 2[3-6][0-9]
            '3'...'6' => {
                if (b.len >= 3 and isDigitByte(b[2])) return true;
            },
            // 27[01], plus the explicit 720 range end
            '7' => {
                if (b.len >= 3 and (b[2] == '0' or b[2] == '1')) return true;
                if (std.mem.startsWith(u8, b, "2720")) return true;
            },
            else => {},
        }
    }
    return false;
}

/// Discover: 6011, 65, 64[4-9], or 622(12[6-9] | 1[3-9][0-9] | [2-8][0-9][0-9] | 9([01][0-9] | 2[0-5])).
fn matchesDiscover(b: []const u8) bool {
    if (std.mem.startsWith(u8, b, "6011")) return true;
    if (b.len < 2 or b[0] != '6') return false;
    if (b[1] == '5') return true; // 65
    if (b[1] == '4' and b.len >= 3 and inRange(b[2], '4', '9')) return true; // 64[4-9]
    // The 622 inner group consumes indices 3, 4, 5.
    if (std.mem.startsWith(u8, b, "622") and b.len >= 6) {
        const c3 = b[3];
        const c4 = b[4];
        const c5 = b[5];
        if (c3 == '1' and c4 == '2' and inRange(c5, '6', '9')) return true; // 622 12[6-9]
        if (c3 == '1' and inRange(c4, '3', '9') and isDigitByte(c5)) return true; // 622 1[3-9][0-9]
        if (inRange(c3, '2', '8') and isDigitByte(c4) and isDigitByte(c5)) return true; // 622 [2-8][0-9][0-9]
        if (c3 == '9') {
            if ((c4 == '0' or c4 == '1') and isDigitByte(c5)) return true; // 622 9[01][0-9]
            if (c4 == '2' and inRange(c5, '0', '5')) return true; // 622 92[0-5]
        }
    }
    return false;
}

/// Diners Club (incl. Carte Blanche / international): 36, 38, 39, 54, 55, 30[0-5], 3095.
fn matchesDiners(b: []const u8) bool {
    const pairs = [_][]const u8{ "36", "38", "39", "54", "55" };
    for (pairs) |p| {
        if (std.mem.startsWith(u8, b, p)) return true;
    }
    if (b.len >= 3 and b[0] == '3' and b[1] == '0' and inRange(b[2], '0', '5')) return true; // 30[0-5]
    // 3095: separate alternative — the 0-5 range above does not cover the '9'.
    if (std.mem.startsWith(u8, b, "3095")) return true;
    return false;
}

/// JCB: 35(2[89] | [3-8][0-9]) — the inner group needs indices 2 and 3.
fn matchesJcb(b: []const u8) bool {
    if (b.len < 4 or b[0] != '3' or b[1] != '5') return false;
    const c2 = b[2];
    const c3 = b[3];
    const two_eight_nine = c2 == '2' and (c3 == '8' or c3 == '9');
    const three_to_eight_digit = inRange(c2, '3', '8') and isDigitByte(c3);
    return two_eight_nine or three_to_eight_digit;
}

/// Maestro: 50, 56-58, or any leading 6 (catch-all for 6xxx issuers).
fn matchesMaestro(b: []const u8) bool {
    if (b.len == 0) return false;
    if (b.len >= 2 and b[0] == '5' and (b[1] == '0' or inRange(b[1], '6', '8'))) return true;
    return b[0] == '6';
}

/// Detect the card brand by prefix + length. Returns null when unknown.
///
/// Order matters: the first prefix+length match wins. Maestro is last because
/// its broad "6" / 5x ranges overlap narrower issuers (Discover, UnionPay, ...).
pub fn detectBrand(digits: []const u8) ?[]const u8 {
    if (digits.len == 0) return null;
    const n = digits.len;

    // Visa: leading 4.
    if (digits[0] == '4' and lenOk(&[_]usize{ 13, 16, 19 }, n)) return "Visa";
    // Mastercard: 51-55, or 2221-2720.
    if (matchesMastercard(digits) and lenOk(&[_]usize{16}, n)) return "Mastercard";
    // American Express: 34 or 37.
    if ((std.mem.startsWith(u8, digits, "34") or std.mem.startsWith(u8, digits, "37")) and
        lenOk(&[_]usize{15}, n)) return "American Express";
    // Discover: 6011, 65, 644-649, or 622126-622925.
    if (matchesDiscover(digits) and lenOk(&[_]usize{ 16, 19 }, n)) return "Discover";
    // Diners Club: 300-305, 3095, 36, 38, 39, 54, 55.
    if (matchesDiners(digits) and lenOk(&[_]usize{ 14, 16 }, n)) return "Diners Club";
    // JCB: 3528-3589.
    if (matchesJcb(digits) and lenOk(&[_]usize{ 16, 17, 18, 19 }, n)) return "JCB";
    // UnionPay: leading 62.
    if (std.mem.startsWith(u8, digits, "62") and lenOk(&[_]usize{ 16, 17, 18, 19 }, n)) return "UnionPay";
    // Maestro: 50, 56-58, or any leading 6.
    if (matchesMaestro(digits) and lenOk(&[_]usize{ 12, 13, 14, 15, 16, 17, 18, 19 }, n)) return "Maestro";
    return null;
}

/// Slice digits into runs of the given widths, joining with spaces.
/// The returned slice is owned by `alloc`.
fn group(alloc: std.mem.Allocator, digits: []const u8, widths: []const usize) ![]u8 {
    var parts = std.ArrayList([]const u8).init(alloc);
    defer parts.deinit();
    var i: usize = 0;
    for (widths) |w| {
        if (i >= digits.len) break;
        const end = @min(i + w, digits.len);
        try parts.append(digits[i..end]);
        i += w;
    }
    // Any trailing remainder (e.g. a partially typed number) is appended as-is.
    if (i < digits.len) {
        try parts.append(digits[i..]);
    }
    return try std.mem.join(alloc, " ", parts.items);
}

/// Insert a space every 4 digits, omitting the trailing space
/// (equivalent to the TS regex `(.{4})(?=.)` replacement).
/// The returned slice is owned by `alloc`.
fn defaultGroup(alloc: std.mem.Allocator, digits: []const u8) ![]u8 {
    var out = std.ArrayList(u8).init(alloc);
    defer out.deinit();
    for (digits, 0..) |ch, i| {
        if (i > 0 and i % 4 == 0) {
            try out.append(' ');
        }
        try out.append(ch);
    }
    return try alloc.dupe(u8, out.items);
}

/// Group the number per brand spacing rules. The returned slice is owned by `alloc`.
pub fn formatCard(alloc: std.mem.Allocator, digits: []const u8, brand: ?[]const u8) ![]u8 {
    if (digits.len == 0) {
        return try alloc.dupe(u8, "");
    }
    // Amex uses a 4-6-5 grouping; Diners Club's 14-digit variant uses 4-6-4.
    if (brand) |name| {
        if (std.mem.eql(u8, name, "American Express")) {
            return group(alloc, digits, &[_]usize{ 4, 6, 5 });
        }
        if (std.mem.eql(u8, name, "Diners Club") and digits.len == 14) {
            return group(alloc, digits, &[_]usize{ 4, 6, 4 });
        }
    }
    return defaultGroup(alloc, digits);
}

/// Validate a card number. Always returns a CardInfo; never panics on malformed
/// input (only allocation failure surfaces, as an error). Free the owned fields
/// with freeCardInfo.
pub fn validateCard(alloc: std.mem.Allocator, input: []const u8) !CardInfo {
    const sanitized = try sanitizeDigits(alloc, input);
    const brand = detectBrand(sanitized);
    // Luhn is only meaningful at plausible card lengths (>= 12 digits).
    const luhn_valid = sanitized.len >= 12 and luhnCheck(sanitized);
    const formatted = try formatCard(alloc, sanitized, brand);
    return .{
        .input = input,
        .sanitized = sanitized,
        .valid = luhn_valid,
        .luhn_valid = luhn_valid,
        .brand = brand,
        .formatted = formatted,
    };
}

/// Release the allocator-owned fields of a CardInfo.
pub fn freeCardInfo(alloc: std.mem.Allocator, info: *CardInfo) void {
    alloc.free(info.sanitized);
    alloc.free(info.formatted);
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const alloc = gpa.allocator();

    // Tiny showcase: validate a well-known test number (Visa).
    var info = try validateCard(alloc, "4111 1111 1111 1111");
    defer freeCardInfo(alloc, &info);

    const brand = info.brand orelse "None";
    const stdout = std.io.getStdOut().writer();
    try stdout.print("brand={s} valid={} formatted=\"{s}\"\n", .{ brand, info.valid, 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 →