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 →