Credit Card Validator — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// credit-card-validator — Luhn checksum + brand detection for card numbers.
//
// Language: Kotlin (1.9, standard library only)
// 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 throws on malformed input; mirrors
// the TypeScript contract.
//
// The brand table mirrors the TS regexes with kotlin.text.Regex (stdlib).
/**
* Full validation result. [brand] is null when the issuer is unknown
* (the Kotlin equivalent of TypeScript's `string | null`).
*/
data class CardInfo(
val input: String,
val sanitized: String, // digits only
val valid: Boolean, // digits present, sane length, and Luhn passes
val luhnValid: Boolean, // Luhn checksum passes (requires >= 12 digits)
val brand: String?, // issuing brand, or null when unknown
val formatted: String, // grouped per brand spacing rules
)
/** Luhn (mod-10) checksum. Empty or non-digit input returns false. */
fun luhnCheck(digits: String): Boolean {
if (digits.isEmpty() || digits.any { it !in '0'..'9' }) {
return false // non-digit -> invalid, matching /^[0-9]*$/
}
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 - '0'
if (shouldDouble) {
d *= 2
if (d > 9) {
d -= 9
}
}
total += d
shouldDouble = !shouldDouble
}
return total % 10 == 0
}
/** Brand detection rule: name, prefix pattern, allowed lengths. */
private data class BrandRule(val name: String, val pattern: Regex, val lengths: Set<Int>)
// Brand detection table. ORDER MATTERS: the first prefix+length match wins.
// Maestro is last because its broad "6" / 5x ranges overlap narrower issuers.
private val BRAND_RULES: List<BrandRule> = listOf(
BrandRule("Visa", Regex("^4"), setOf(13, 16, 19)),
BrandRule("Mastercard", Regex("^(5[1-5]|2(2[2-9]|[3-6][0-9]|7[01]|720))"), setOf(16)),
BrandRule("American Express", Regex("^3[47]"), setOf(15)),
BrandRule(
"Discover",
Regex("^(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])))"),
setOf(16, 19),
),
BrandRule("Diners Club", Regex("^(30[0-5]|3095|36|38|39|54|55)"), setOf(14, 16)),
BrandRule("JCB", Regex("^35(2[89]|[3-8][0-9])"), setOf(16, 17, 18, 19)),
BrandRule("UnionPay", Regex("^62"), setOf(16, 17, 18, 19)),
BrandRule("Maestro", Regex("^(50|56|57|58|6)"), (12..19).toSet()),
)
/** Detect the card brand by prefix + length. Returns null when unknown. */
fun detectBrand(digits: String): String? {
if (digits.isEmpty()) {
return null
}
for (rule in BRAND_RULES) {
// containsMatchIn with an anchored "^" pattern looks only at the prefix,
// mirroring Python's pattern.search on these anchored regexes.
if (rule.pattern.containsMatchIn(digits) && digits.length in rule.lengths) {
return rule.name
}
}
return null
}
// Slice digits into runs of the given widths, joining with spaces.
private fun group(digits: String, vararg widths: Int): String {
val parts = mutableListOf<String>()
var i = 0
val n = digits.length
for (w in widths) {
if (i >= n) break
parts += digits.substring(i, minOf(i + w, n)) // substring clamps the run at the end
i += w
}
// Any trailing remainder (e.g. a partially typed number) is appended as-is.
if (i < n) {
parts += digits.substring(i)
}
return parts.joinToString(" ")
}
/** Group the number per brand spacing rules. */
fun 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.length == 14) {
return group(digits, 4, 6, 4)
}
// Default: groups of four separated by spaces, no trailing space
// (equivalent to the TS regex `(.{4})(?=.)` replacement).
return digits.chunked(4).joinToString(" ")
}
/** Keep ASCII digit characters only. */
private fun sanitizeDigits(input: String): String = input.filter { it in '0'..'9' }
/** Validate a card number. Always returns a CardInfo; never throws. */
fun validateCard(input: String): CardInfo {
val sanitized = sanitizeDigits(input)
val brand = detectBrand(sanitized)
// Luhn is only meaningful once we have a plausible card length (>= 12 digits).
val luhnValid = sanitized.length >= 12 && luhnCheck(sanitized)
return CardInfo(
input = input,
sanitized = sanitized,
valid = luhnValid,
luhnValid = luhnValid,
brand = brand,
formatted = formatCard(sanitized, brand),
)
}
fun main() {
// Tiny showcase: validate a well-known test number (Visa).
val info = validateCard("4111 1111 1111 1111")
println("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 →