Luhn Algorithm Credit Card Validation
How do you tell, in the browser and before any network request, that a user has mistyped their card number? The Luhn algorithm — a mod-10 checksum built into every payment card number — answers exactly that question. This recipe implements it in TypeScript without string allocations, explains the arithmetic with a worked example, wires the verdict into setCustomValidity() so the Constraint Validation API reports it like any other rule, and spells out the classes of error it catches and the ones it silently lets through.
When to Use the Luhn Check
Use Luhn whenever you render your own input for a number that carries a Luhn check digit. That includes:
- Payment card numbers (all major networks).
- IMEI numbers on device registration forms.
- Canadian Social Insurance Numbers and several national identifiers.
- Some loyalty and gift card numbers — check the issuer’s specification first.
Do not use it to decide whether a card is genuine; it has no idea whether an account exists. Do not use it on IBANs either — those use a different, stronger mod-97 checksum covered in IBAN validation with mod 97. And run it after the brand-specific length check from the payment card validation topic: reporting “a digit is wrong” on a 12-digit Amex number is less helpful than “American Express card numbers are 15 digits long”.
Minimal Working Luhn Implementation
The implementation reads character codes directly instead of splitting into an array, so it allocates nothing and runs in well under a microsecond for a 19-digit number. It expects digits only; normalise first.
/**
* Luhn (mod 10) checksum. Expects a string of ASCII digits only.
* Returns false for empty input so an empty field never "passes".
*/
export function luhnValid(digits: string): boolean {
if (digits.length === 0) return false;
let sum = 0;
let double = false;
for (let i = digits.length - 1; i >= 0; i--) {
const code = digits.charCodeAt(i);
if (code < 48 || code > 57) return false; // defensive: non-digit
let n = code - 48;
if (double) {
n *= 2;
if (n > 9) n -= 9; // same as summing the two digits of n
}
sum += n;
double = !double;
}
return sum % 10 === 0;
}
/** Compute the check digit to append to a partial number (for test fixtures). */
export function luhnCheckDigit(partial: string): number {
for (let d = 0; d <= 9; d++) if (luhnValid(partial + d)) return d;
throw new Error("unreachable");
}
// Wiring into the canonical novalidate + reportValidity flow
const form = document.querySelector<HTMLFormElement>("#checkout")!;
const field = form.querySelector<HTMLInputElement>("#cc-number")!;
const MESSAGE = "Check your card number — it looks like a digit is wrong.";
function validateCardNumber(): void {
const digits = field.value.replace(/[\s-]/g, "");
const lengthOk = digits.length >= 12 && digits.length <= 19 && /^\d+$/.test(digits);
// Only judge the checksum once the number could be complete.
field.setCustomValidity(lengthOk && !luhnValid(digits) ? MESSAGE : "");
}
field.addEventListener("input", validateCardNumber);
form.addEventListener("submit", (event) => {
validateCardNumber();
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
A worked example
Take the common test number 4539 1488 0343 6467. Reading from the right, the digits in odd positions (1st, 3rd, 5th…) are kept as they are, and those in even positions are doubled:
| Position from right | Digit | Doubled? | Value added |
|---|---|---|---|
| 1 | 7 | no | 7 |
| 2 | 6 | yes → 12 → 1+2 | 3 |
| 3 | 4 | no | 4 |
| 4 | 6 | yes → 12 → 3 | 3 |
| 5 | 3 | no | 3 |
| 6 | 4 | yes → 8 | 8 |
| 7 | 3 | no | 3 |
| 8 | 0 | yes → 0 | 0 |
| 9 | 8 | no | 8 |
| 10 | 8 | yes → 16 → 7 | 7 |
| 11 | 4 | no | 4 |
| 12 | 1 | yes → 2 | 2 |
| 13 | 9 | no | 9 |
| 14 | 3 | yes → 6 | 6 |
| 15 | 5 | no | 5 |
| 16 | 4 | yes → 8 | 8 |
The values sum to 80, which is divisible by 10, so the number passes. Change any single digit and the sum moves by a non-zero amount smaller than ten, so it can no longer be a multiple of ten — that is why every single-digit error is caught.
Luhn Validator Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
| Input normalisation | regex | /[\s-]/g |
Remove formatting before checking |
| Minimum length to judge | number |
12 |
Avoid flagging a number that is still being typed |
| Maximum length | number |
19 |
Longest issued card number |
| Error message | string |
“Check your card number…” | Does not claim which digit is wrong |
| Empty input result | boolean |
false |
Empty never passes; required reports it instead |
| Trigger | event | input + submit |
Live correction plus the submit-time safety net |
Verification Steps
import { describe, it, expect } from "vitest";
import { luhnValid, luhnCheckDigit } from "./luhn";
describe("luhnValid", () => {
it.each([
["4539148803436467", true],
["4539148803436468", false], // last digit changed
["4539148803436476", false], // adjacent swap 67 → 76
["79927398713", true],
["", false],
])("%s → %s", (input, expected) => expect(luhnValid(input)).toBe(expected));
it("generates fixtures that validate", () => {
const partial = "411111111111111";
expect(luhnValid(partial + luhnCheckDigit(partial))).toBe(true);
});
});
Edge Cases and Failure Modes
The 09 ↔ 90 transposition. Swapping adjacent 0 and 9 leaves the sum unchanged: doubled 9 folds to 9, doubled 0 stays 0, so both orders contribute 9. Luhn cannot catch this one, and no amount of client code will — the provider’s decline is the backstop.
Checking too early. Running Luhn from the first keystroke flashes “a digit is wrong” at almost every intermediate length, because roughly 90% of prefixes fail the checksum by chance. Gate the check on a plausible complete length, as the implementation does, or on the brand’s exact length once it is known.
Non-ASCII digits. Users with some keyboard layouts can type full-width digits (4242) or Arabic-Indic digits. charCodeAt returns values outside 48–57 for these, and the defensive branch rejects them. Better: normalise them first with value.normalize("NFKC"), which turns full-width digits into ASCII, and give a specific message for anything that remains.
const digits = field.value.normalize("NFKC").replace(/[\s-]/g, "");
Why Luhn Catches What It Catches
The design is elegant once you see it. Doubling alternate digits means each position contributes differently to the sum, so moving a digit to a neighbouring position usually changes the total; folding values above nine keeps every contribution a single digit, so the sum is bounded and cheap to compute. A single substituted digit changes its contribution by between 1 and 9 (whether or not it is in a doubled position, because doubling-and-folding maps 0–9 onto 0–9 as a permutation), so the sum can never shift by exactly ten — detection is guaranteed. For adjacent swaps, the change in the sum is the difference between the two digits’ doubled and undoubled values, which is non-zero for every pair except 0 and 9. That is the whole algorithm’s strength and its limit: it was designed in the 1950s to catch human keying errors cheaply, and it still does precisely that.
This also explains why Luhn is not a security feature. Anyone can compute a valid check digit for any prefix — the luhnCheckDigit helper above does it in a loop — so “passes Luhn” carries no information about authenticity. Treat the check as a typo detector that saves the user a declined payment, in the same spirit as the synchronous validation patterns elsewhere on the site, and let the provider’s authorisation be the real verdict.
Performance: Luhn on Every Keystroke
Because the implementation walks at most 19 characters with integer arithmetic, running it on every input event is effectively free — measured in tens of nanoseconds. There is no reason to debounce it, and debouncing would only delay the moment an error clears after the user corrects a digit. If profiling shows your card field is slow, the cost is almost always elsewhere: a formatter that rebuilds the value with regular expressions on every keystroke, a brand-detection table compiled inside the handler, or a framework re-render triggered by setting state three times. The guidance in memoizing expensive synchronous validators applies to those, not to the checksum itself.
Frequently Asked Questions
Does the Luhn check prove a credit card number is valid?
It proves the number is internally consistent, which catches almost all typing mistakes. It says nothing about whether the account exists, is open, or has funds; only an authorisation through your payment provider can tell you that.
Which errors does Luhn fail to detect?
It misses swaps of adjacent 0 and 9 (09 ↔ 90), some twin errors such as 22 ↔ 55, and transpositions of non-adjacent digits. These are rare in practice, and the provider decline covers them.
Should I run the Luhn check on every keystroke?
Compute it on every input — it is extremely cheap — but only show a checksum error once the number reaches a plausible complete length. Otherwise most partial numbers flash a false error.
Can I use the same code for IMEI numbers?
Yes. IMEIs are 15 digits with a Luhn check digit, so the same function works; just change the length gate and the error message.
Related Guides
- Payment Card Validation — the full card field pipeline around the checksum.
- Detecting Card Brand and Formatting Input — brand-specific lengths that run before Luhn.
- Composing Pure Validator Functions — combining Luhn with other rules cleanly.
- Property-Based Testing Validators with fast-check — proving the single-digit guarantee with generated inputs.