Ahead of the release
npm install @evanion/luhn gives you 3.0.0. These pages document main, which has changes that release does not.
Migrating from 2.x
Every check character changes. The default dictionary goes from 62 characters to 36, so every index changes, and tokens minted by 2.x do not validate under 3.x.
Plan for that before you plan for the API. Options, in order of how much they cost:
- Re-mint. Fine while the codes are internal or short-lived.
- Keep 2.x’s alphabet:
createLuhn({ dictionary: ALTERNATING_CASE_DICTIONARY })reproduces 2.x’s case-sensitive values exactly. 2.x’s case-insensitive default has no 3.x equivalent, because the 62-character dictionary contains case pairs and folding over it was never sound. - Validate against both during a transition, and re-mint on read.
The API
| 2.x | 3.x |
|---|---|
Luhn.generate(input) | unchanged call, new value |
Luhn.validate(input) | unchanged call; it now accepts every token generate produces |
Luhn.generate(input, true) | createLuhn({ caseInsensitive: false }).generate(input) |
class X extends Luhn { static dictionary = d } | createLuhn({ dictionary: d, caseInsensitive: true }) |
class X extends Luhn { static sensitive = true } | createLuhn({ dictionary: ALTERNATING_CASE_DICTIONARY }) — same values |
Luhn.dictionary = d | TypeError; the default instance is frozen |
Luhn.sensitive | Luhn.caseInsensitive |
Luhn.lowercaseOnly(d) | removed; nothing folds a dictionary any more |
the protected static helpers | removed; the one reduce read was bound to Luhn and not overridable |
ValidationError | LuhnError |
validate('') returning isValid: true | isValid: false |
generate('') returning { checksum: '0' } | throws EmptyInputError |
| an odd dictionary throwing at the first call | throws at createLuhn |
Luhn.generate(x) and Luhn.validate(x) still compile and still run. Luhn is
now the default instance rather than a class, which is what keeps those two call
sites working through the rewrite.
Why the class went
The 2.x configuration idiom was a mutable static read on every call, which meant there was no moment at which a dictionary was known, checked and turned into lookup tables. Three consequences, all of them shipped bugs:
- Any precomputed table keyed on a static is stale the moment the static is assigned, so memoisation had to be a cache keyed on the source string that re-derived whenever it changed.
- A static is a name match, not a type match. A subclass that set
dictionaryto a function passed the parity check on the function’s arity, and failed four frames deeper withTypeError: dictionary.indexOf is not a function. - Part of the surface was not overridable at all.
reducewas a static arrow field, so thethisit readchar2indexoff wasLuhnwhatever class the call started on, and a subclass override ofchar2indexwas a silent no-op.
createLuhn gives that moment: the dictionary is checked and turned into lookup
tables once. The assignment that used to configure it now throws, in a module,
in strict mode, instead of being accepted and ignored.
Why the default alphabet shrank
2.x’s 62-character default contained case pairs, and its documented behaviour
was case-insensitive. Those two cannot both hold: folding maps A onto a, and
the index A occupied becomes unreachable.
36 lowercase alphanumerics is the alphabet on which the documented default is
sound. ALTERNATING_CASE_DICTIONARY is still exported for anyone who wants the
62 characters, and is case-sensitive only.
A per-request dictionary
2.x let a subclass carry one. 3.x makes it an instance:
const luhn = createLuhn({ dictionary: tenant.alphabet });That is one pass over the dictionary — the pass that would have happened anyway to validate it. Cache by alphabet if the same few recur.