Skip to Content
LuhnMigrating from 2.x

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.x3.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 = dTypeError; the default instance is frozen
Luhn.sensitiveLuhn.caseInsensitive
Luhn.lowercaseOnly(d)removed; nothing folds a dictionary any more
the protected static helpersremoved; the one reduce read was bound to Luhn and not overridable
ValidationErrorLuhnError
validate('') returning isValid: trueisValid: false
generate('') returning { checksum: '0' }throws EmptyInputError
an odd dictionary throwing at the first callthrows 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 dictionary to a function passed the parity check on the function’s arity, and failed four frames deeper with TypeError: dictionary.indexOf is not a function.
  • Part of the surface was not overridable at all. reduce was a static arrow field, so the this it read char2index off was Luhn whatever class the call started on, and a subclass override of char2index was 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.

Last updated on