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
is_luhn(79927398713)→ true the published Luhn worked example 79927398713is_luhn(79927398710)→ false the same example with its last digit wrongis_luhn(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.
pub fn is_luhn(value: &str) -> bool
| 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
fune!(validation.luhn@^1); // then call is_luhn(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// 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.
pub fn is_luhn(value: &str) -> bool {
let mut digits: Vec<u32> = Vec::new();
for ch in value.chars() {
// Card numbers are printed and pasted in four-digit groups, so
// tolerating the separators here saves every caller the same strip.
if ch == ' ' || ch == '-' {
continue;
}
if ch < '0' || ch > '9' {
return false;
}
digits.push(ch as u32 - 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.len() < 2 {
return false;
}
let mut total: u32 = 0;
for (position, digit) in digits.iter().rev().enumerate() {
let mut d = *digit;
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;
}
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.
/// Returns -1 when the prefix is not usable digits at all.
pub fn luhn_check_digit(prefix: &str) -> i64 {
let mut digits: Vec<u32> = Vec::new();
for ch in prefix.chars() {
if ch == ' ' || ch == '-' {
continue;
}
if ch < '0' || ch > '9' {
return -1;
}
digits.push(ch as u32 - 48);
}
if digits.is_empty() {
return -1;
}
let mut total: u32 = 0;
// The check digit will occupy position 0, so the prefix starts at 1.
for (position, digit) in digits.iter().rev().enumerate() {
let mut d = *digit;
if position % 2 == 0 {
d *= 2;
if d > 9 {
d -= 9;
}
}
total += d;
}
((10 - (total % 10)) % 10) as i64
}
pub fn fune_vector(args: &[Value]) -> Value {
// A non-string argument arrives here as an empty string, which is exactly
// the answer TypeScript and Python give for a non-string: not valid.
Value::Bool(is_luhn(args[0].as_str()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add validation.luhn
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.luhn-1.0.0-rust.fune, or fetch it from a terminal with fune pull validation.luhn@1.0.0:rust.
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 |