validation.luhn
Check a digit string against the Luhn mod-10 checksum used by payment cards and many identifiers.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
Luhn is a transcription check, not an authorisation. A number that passes is well formed: a single mistyped digit, and every adjacent transposition except 0<->9, would have failed. A number that passes may still be unissued, closed, stolen or empty. Only the card scheme or issuer can answer that, so never show a user "card valid" because this returned true - the honest wording is "that doesn't look like a complete card number" on false, and nothing at all on true.
Accepted input: ASCII digits, optionally separated by ASCII spaces or hyphens anywhere (including leading and trailing), because cards are printed and pasted in four-digit groups. Any other character, including underscores, dots, non-breaking spaces and non-ASCII digits, makes the whole value invalid rather than being skipped.
For example
isLuhn(79927398713)→ true the published Luhn worked example 79927398713isLuhn(79927398710)→ false the same example with its last digit wrongisLuhn(4242424242424242)→ true a Visa test card number
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function isLuhn(value: string): boolean
| value | string | Digits, optionally grouped with ASCII spaces or hyphens |
| returns | bool |
Your code names it in one line, in the file that uses it
import { isLuhn } from "#fune/validation.luhn@^1";
/**
* The Luhn (mod 10) checksum, as used by payment card numbers, IMEIs,
* SIM ICCIDs and a long tail of national identifiers.
*
* Luhn is a *transcription* check. It catches a single mistyped digit and
* most adjacent transpositions before a request leaves the building. It says
* nothing about whether the card exists, is open, belongs to the person
* typing it, or has any money behind it. Only the acquirer can answer that,
* so never render "card valid" on the strength of this function.
*/
export function isLuhn(value: string): boolean {
// Non-strings are answered rather than thrown at: "is this valid?" is a
// question, and "no" is a complete answer to it.
if (typeof value !== "string") return false;
const digits: number[] = [];
for (const ch of value) {
// Card numbers are printed and pasted in four-digit groups, so tolerating
// the separators here saves every caller from writing the same strip.
if (ch === " " || ch === "-") continue;
if (ch < "0" || ch > "9") return false;
digits.push(ch.charCodeAt(0) - 48);
}
// A lone digit satisfies the arithmetic whenever it is 0, which would make
// this function a rubber stamp for a stray keystroke. No identifier scheme
// issues one-digit numbers, so two is the honest floor.
if (digits.length < 2) return false;
let total = 0;
let position = 0;
for (let i = digits.length - 1; i >= 0; i--, position++) {
let d = digits[i];
if (position % 2 === 1) {
d *= 2;
// Doubling can only reach 18, so subtracting 9 is the same as summing
// the two decimal digits, without the string round trip.
if (d > 9) d -= 9;
}
total += d;
}
return total % 10 === 0;
}
/**
* The digit that would make `prefix` pass the Luhn check.
*
* Useful for generating test data and for completing a partially known
* number; it is the inverse of the check above, not a second opinion on it.
*/
export function luhnCheckDigit(prefix: string): number {
const digits: number[] = [];
for (const ch of prefix) {
if (ch === " " || ch === "-") continue;
if (ch < "0" || ch > "9") return -1;
digits.push(ch.charCodeAt(0) - 48);
}
if (digits.length === 0) return -1;
let total = 0;
let position = 0;
// The check digit will occupy position 0, so the prefix starts at 1.
for (let i = digits.length - 1; i >= 0; i--, position++) {
let d = digits[i];
if (position % 2 === 0) {
d *= 2;
if (d > 9) d -= 9;
}
total += d;
}
return (10 - (total % 10)) % 10;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add validation.luhn
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./validation.luhn-1.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.luhn@1.0.0:typescript.
The whole function, every language, is one file too: validation.luhn-1.0.0.fune, 13,049 bytes, sha256 0ca77e1af7ac5c6493cdc4737614cd723c89e632555685d909d4dd957a24d1d6. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before validation.luhn
after — your function gets the result and the arguments, and returns the final result.
// fune: after validation.luhn
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.luhn --steps.
// fune: step validation.luhn after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| the published Luhn worked example 79927398713 | 79927398713 | → | true |
| the same example with its last digit wrong | 79927398710 | → | false |
| a Visa test card number | 4242424242424242 | → | true |
| the same card printed in four-digit groups | 4242 4242 4242 4242 | → | true |
| the same card hyphenated | 4242-4242-4242-4242 | → | true |
| a Mastercard test card number | 5555555555554444 | → | true |
| an American Express test card number, fifteen digits | 378282246310005 | → | true |
| a published IMEI, so Luhn is not only about cards | 490154203237518 | → | true |
| right length, last digit off by one | 4242424242424241 | → | false |
| two adjacent digits transposed | 378282246310050 | → | false |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the empty string is not a number | → | false | |
| separators with no digits between them | - | → | false |
| a single zero satisfies the arithmetic but is refused | 0 | → | false |
| two digits is the shortest accepted input | 00 | → | true |
| underscore is not an accepted separator | 4242_4242_4242_4242 | → | false |
| a letter anywhere fails | 4242 4242 4242 424X | → | false |
| leading and trailing spaces are ignored like any other separator | 4111 1111 1111 1111 | → | true |
| a non-string argument is not a number | 42 | → | false |
More from the author
At least two digits are required. A single "0" satisfies the arithmetic, and accepting it would turn this into a rubber stamp for a stray keystroke.
This capability deliberately does not check length or issuer prefix. A 16-digit Visa, a 15-digit Amex and a 15-digit IMEI all use the same checksum, and baking card-brand rules in here would make it useless for everything else. Pair it with a separate brand check if you need one.
Validators answer rather than throw: an unparseable value is not an exceptional condition, it is the answer "no". A non-string argument is therefore false, not a TypeError.
Files
| Path | Bytes |
|---|---|
| README.md | 1,424 |
| impl/python.py | 2,577 |
| impl/rust.rs | 2,949 |
| impl/typescript.ts | 2,547 |
| vectors.json | 1,794 |