Ahead of the release
npm install @evanion/luhn gives you 3.0.0. These pages document main, which has changes that release does not.
Generating and validating
Two operations, both total apart from one thrown error, and both returning the filtered phrase alongside their answer.
import { Luhn } from '@evanion/luhn';generate
.('foo'); // -> { phrase: 'foo', checksum: '5', filtered: 0 }
.('FoO'); // -> { phrase: 'foo', checksum: '5', filtered: 0 }Luhn.generate('foo')Retype the phrase, or hyphenate it, and watch the check character.
checksum is one character drawn from the dictionary. It is not appended for
you — the caller decides where it goes and what separates it:
const body = 'inv0ice';
const { checksum } = Luhn.generate(body);
`${body}-${checksum}`; // 'inv0ice-g'phrase is the input with everything outside the dictionary removed, which is
what the check character was actually computed over. It is returned because the
two are not always the same string, and a caller storing the phrase should store
what was checked.
Empty input throws
Luhn.generate('');
// EmptyInputError: Luhn cannot generate a check character over an input
// with no dictionary code points (received "").
Luhn.generate('!!!!'); // the same errorA check character over no payload carries no information, and returning one
makes generate('') and generate('!!!!') indistinguishable. This is the only
error either operation raises.
validate
The last dictionary character of the input is the check character.
Luhn.validate('foo5'); // { phrase: 'foo5', isValid: true, filtered: 0 }
Luhn.validate('FOO5'); // { phrase: 'foo5', isValid: true, filtered: 0 }
Luhn.validate('FoO-ö5'); // { phrase: 'foo5', isValid: true, filtered: 2 }
Luhn.validate('bar5'); // { phrase: 'bar5', isValid: false, filtered: 0 }It never throws. Fewer than two surviving characters is not valid — a payload and a check character is the minimum:
Luhn.validate(''); // { phrase: '', isValid: false, filtered: 0 }
Luhn.validate('!!!!0'); // { phrase: '0', isValid: false, filtered: 4 }phrase here includes the check character, because that is what was validated.
generate’s phrase does not, because the check character did not exist yet.
Filtering
Characters outside the dictionary are dropped before anything is computed, so a hyphenated or accented rendering of the same token checks the same:
Luhn.generate('foo-baz'); // { phrase: 'foobaz', checksum: 'p', filtered: 1 }
Luhn.generate('fooö-baz'); // { phrase: 'foobaz', checksum: 'p', filtered: 2 }This is what makes a printed code survive being typed back in with the grouping in the wrong place, or without it:
Luhn.generate('order-2026-0042');
// { phrase: 'order20260042', checksum: 'l', filtered: 2 }
Luhn.validate('order-2026-0042l').isValid; // true
Luhn.validate('ORDER20260042L').isValid; // true
Luhn.validate('or-der-2026-0042-l').isValid; // truefiltered counts the dropped characters, so a caller who wants strict input can
gate on it rather than reimplementing the filter:
const result = Luhn.validate(input);
if (!result.isValid) return reject('bad code');
if (result.filtered > 0) return reject('unexpected characters');Counting is by code point, not by UTF-16 unit, so an astral character is one drop rather than two.
Type the order number below back in and watch the verdict. The grouping and the case make no difference to it, because both are filtered before anything is computed; break the code with either button and it is refused.
Issued
The last character is the check. Break the code and it stops matching.
Case folding
The default instance folds input to lowercase before looking it up. That is a
property of the dictionary rather than of the call — see
Dictionaries — and it is why phrase
comes back lowercased:
Luhn.generate('FoO'); // { phrase: 'foo', checksum: '5', filtered: 0 }Over a case-sensitive dictionary, the input’s case is preserved and is part of what is checked:
import { ALTERNATING_CASE_DICTIONARY, createLuhn } from '@evanion/luhn';
const sensitive = createLuhn({ dictionary: ALTERNATING_CASE_DICTIONARY });
sensitive.generate('FoO'); // { phrase: 'FoO', checksum: 'K', filtered: 0 }
sensitive.validate('FoOK').isValid; // true
sensitive.validate('fook').isValid; // falseA minted-identifier helper
Putting the two together, with the check character in a fixed position:
import { Luhn } from '@evanion/luhn';
export function sign(body: string): string {
const { checksum } = Luhn.generate(body);
return `${body}-${checksum}`;
}
export function verify(code: string): string | null {
const { isValid, phrase } = Luhn.validate(code);
return isValid ? phrase.slice(0, -1) : null;
}
sign('4a7f2c9'); // '4a7f2c9-r'
verify('4a7f2c9-r'); // '4a7f2c9'
verify('4a7f2c9-x'); // nullverify returns the body from phrase rather than from the caller’s string,
because phrase is the filtered form and is what sign checked. Slicing the
raw input instead would return the separator on a differently-grouped rendering
of the same code.
If you want this whole shape ready-made — random body, chunking, prefix,
a confusion-free alphabet — that is @evanion/token.