Unreleased changes
These pages document main, which has changes 3.0.2 does not. npm install @evanion/luhn gives you 3.0.2, documented here. Values on these pages are produced by running the current source on every build.
API reference
@evanion/luhn exports one factory, the default instance Luhn (also the name
of its type), two dictionaries, four other types and three error classes, one
heading each below. It runs on Node 20 or newer, as an ES module.
Validates options.dictionary and returns a frozen object carrying it, its
lookup tables, and the two operations bound to them.
throws {InvalidDictionaryError} when the dictionary fails a constraint.
Full documentation
Everything a dictionary has to satisfy is checked here, once. Nothing is
checked at use, so an accepted instance cannot produce a code its own
validate rejects.
The example’s 32 characters are the default 36 without i, l, o and w,
which are confused when read or heard. LuhnOptions lists the
two options and their defaults, and
InvalidDictionaryReason lists the five
constraints a dictionary can fail.
function (?: LuhnOptions): Luhn;
What the tests state
13 behaviours
the constructed instance
dictionary constraints
exposes the dictionary it was given
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { } from '@evanion/luhn';
const = ({ : '0123456789abcdefghjkmnpqrstuvxyz' });
.('a4kp9mx'); // -> { phrase: 'a4kp9mx', checksum: 'a', filtered: 0 }
.('a4kp-9mxa').; // -> true
.; // -> 32
.; // -> falseA dictionary with generate and validate bound to it.
The default instance, which is what createLuhn() returns with no options:
the 36 lowercase alphanumerics, folding case.
Full documentation
Frozen, so Luhn.dictionary = x throws a TypeError in strict mode, which
every ES module runs in, rather than being accepted and ignored. A second
dictionary is a second instance, built with createLuhn.
Luhn names the interface in type space and the default instance in value
space, so const luhn: Luhn = Luhn compiles. Every instance createLuhn
returns carries the same members:
| Member | What it is |
|---|---|
dictionary | the alphabet, as supplied |
n | the modulus: the number of code points in the dictionary |
caseInsensitive | whether input is folded to lowercase before lookup |
uniformOverBytes | whether n divides 256, so byte % n picks each character equally often |
generate(input) | returns a GenerateResult; throws EmptyInputError |
validate(input) | returns a ValidateResult for a code ending in its check character |
interface Luhn {
readonly : string;
readonly : number;
readonly : boolean;
readonly : boolean;
(: string): GenerateResult;
(: string): ValidateResult;
}
let : unknown;
try {
( as { : string }). = '0123456789';
} catch () {
= ;
}
instanceof ; // -> true
.; // -> '0123456789abcdefghijklmnopqrstuvwxyz'What createLuhn builds an instance from. Both fields are optional, and
createLuhn() with neither builds what Luhn holds: the 36 lowercase
alphanumerics, folding case.
| Option | Default |
|---|---|
dictionary | DEFAULT_DICTIONARY |
caseInsensitive | true when dictionary is omitted, false when it is given |
caseInsensitive: true over a dictionary holding case pairs throws
InvalidDictionaryError with reason: 'case-pairs'.
interface LuhnOptions {
?: string;
?: boolean;
}
const = ({
: '0123456789abcdefghjkmnpqrstuvxyz',
: true,
});
.('A4KP-9MXA').; // -> false
.('A4KP-9MXA').; // -> true
const = ({ : });
.('Order-2026-0042').; // -> 'F'
.('Order-2026-0042-F').; // -> true
.('order-2026-0042-F').; // -> falseThe filtered phrase and its check character.
phrase does not include checksum, because the check character did not
exist when the phrase was read.
interface GenerateResult {
: string;
: string;
: number;
}
.('order-2026-0042'); // -> { phrase: 'order20260042', checksum: 'l', filtered: 2 }
.('ORDER 2026/0042'); // -> { phrase: 'order20260042', checksum: 'l', filtered: 2 }The filtered phrase and whether its last character checks out.
isValid is false when phrase holds fewer than two code points. phrase
includes the check character, because that is what was validated, so the
payload without separators is phrase.slice(0, -1).
interface ValidateResult {
: string;
: boolean;
: number;
}
.('order-2026-0042-l'); // -> { phrase: 'order20260042l', isValid: true, filtered: 3 }
.('ORDER20260042L'); // -> { phrase: 'order20260042l', isValid: true, filtered: 0 }
.('order-2026-0043-l'); // -> { phrase: 'order20260043l', isValid: false, filtered: 3 }
.('l'); // -> { phrase: 'l', isValid: false, filtered: 0 }
.('--'); // -> { phrase: '', isValid: false, filtered: 2 }36 lowercase alphanumerics, the dictionary createLuhn() uses when the
caller supplies none.
Full documentation
It contains no case pairs, which is what makes case folding sound over it: folding maps uppercase input onto a dictionary entry instead of onto a second entry that is already taken.
export declare const = "0123456789abcdefghijklmnopqrstuvwxyz";import { , } from '@evanion/luhn';
.; // -> '0123456789abcdefghijklmnopqrstuvwxyz'
. === ; // -> true
.; // -> 36
.; // -> true62 characters, digits followed by Aa Bb Cc …. Case-sensitive only: its
case pairs make it invalid under caseInsensitive.
Full documentation
The alternating order decides which index each letter occupies, and therefore every check character the dictionary produces.
createLuhn({ dictionary: ALTERNATING_CASE_DICTIONARY }) reproduces the check
characters 2.x produced with static sensitive = true.
export declare const = "0123456789AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz";let : | undefined;
try {
({
: ,
: true,
});
} catch () {
if ( instanceof ) = ;
}
?.; // -> 'case-pairs'
?..(0, 3); // -> ['Aa', 'Bb', 'Cc']Base class for every error this library throws. Catch it to handle any failure without naming the subclasses.
Full documentation
The name carries the package, so it never collides with another library’s
ValidationError in a consumer’s import list.
export declare class extends {
constructor(: string);
}What the tests state
1 behaviour
LuhnError
is an Error
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/luhn';
function (: string, : string): string {
try {
return ({ }).().;
} catch () {
if ( instanceof ) return .;
throw ;
}
}
('0123456789', '20260042'); // -> '5'
('0123456789', 'ORD'); // -> 'EmptyInputError'
('012345678', '20260042'); // -> 'InvalidDictionaryError'Thrown by createLuhn when the dictionary cannot support Luhn mod-N.
Full documentation
One class carrying a reason rather than one class per constraint, so a
caller that only wants to know whether a dictionary is acceptable writes one
catch arm instead of five.
| Property | What it holds |
|---|---|
reason | the InvalidDictionaryReason that failed |
dictionary | the dictionary as supplied |
offending | the repeated code points, or the case pairs joined as 'Aa'; empty for the other three |
export declare class extends {
readonly : ;
readonly : string;
readonly : readonly string[];
constructor(: , : string, : readonly string[]);
}What the tests state
5 behaviours
InvalidDictionaryError
carries a name matching the class
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/luhn';
function (: string) {
try {
({ });
return null;
} catch () {
if (!( instanceof )) throw ;
return { : ., : . };
}
}
('0123456789abcdefghjkmnpqrstuvxyz'); // -> null
('0123456789abcdefghjkmnpqrstuvxy'); // -> { reason: 'odd-length', offending: [] }
('0123456789abcdefghjkmnpqrstuvxyy'); // -> { reason: 'duplicate', offending: ['y'] }
('0'); // -> { reason: 'too-short', offending: [] }Which construction constraint the dictionary failed.
reason | What the dictionary must be |
|---|---|
not-a-string | a string |
too-short | at least 2 code points long |
odd-length | an even number of code points long |
duplicate | free of repeated code points |
case-pairs | free of case variants of one letter, when caseInsensitive is true |
An odd size is refused because a character at a doubled position contributes
floor(2x / n) + (2x mod n) for its index x, and over an odd n two indices
contribute the same amount, so a substitution between them goes uncaught.
export type = 'not-a-string' | 'too-short' | 'odd-length' | 'duplicate' | 'case-pairs';Thrown by generate when no code point of the input is in the dictionary.
Full documentation
A check character over no payload carries no information, and returning one makes every all-filtered input produce the same result.
EmptyInputError is the only error an instance’s calls raise, and only
generate raises it. Every other constraint was settled when the instance was
built.
export declare class extends {
constructor(: string);
}What the tests state
1 behaviour
EmptyInputError
is a LuhnError with a name matching the class
Reading the case from the package.
Each line is the name of a test in the package. A test states a behaviour; whether it proves one is a question its own source answers, and the pane carries that source.
import { , } from '@evanion/luhn';
function (: string): string | null {
try {
return .().;
} catch () {
if ( instanceof ) return null;
throw ;
}
}
('order-2026-0042'); // -> 'l'
('--'); // -> null