Unreleased changes
These pages document main, which has changes 0.1.2 does not. npm install @evanion/token gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.
API reference
@evanion/token exports createToken, the types it takes and returns, five
constants and three error classes, one heading each below. It needs Node 20 or
newer and ships as ESM, which require() loads on Node 20.19, 22.12 or newer.
Validates the options and returns a frozen object carrying them and the two operations bound to them.
throws {InvalidAlphabetError} when the dictionary holds a character of
CONFUSABLE_CHARACTERS or an uppercase one, or has a size that does
not divide 256. · throws {InvalidShapeError} when length, chunkSize and separator
cannot describe a code. · throws {InvalidDictionaryError} from @evanion/luhn, when the dictionary
cannot carry a check character.
Full documentation
Everything is checked here, once. Nothing is checked at use, so an accepted
instance cannot produce a code its own validate rejects.
function (?: TokenOptions): Token;
What the tests state
6 behaviours
the constructed instance
the default dictionary
exposes the configuration 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.
const = ({ : 16, : 4 });
.()..; // -> 19
.; // -> 75A configuration with generate and validate bound to it.
Token is the frozen object createToken returns.
| Member | What it is |
|---|---|
generate | mints a code; never retries and never checks for collision |
validate | reports whether a string is a well-formed code |
dictionary | the alphabet, as configured |
n | the number of characters in the dictionary |
length, chunkSize, separator | the shape, as configured |
entropyBits | (length - 1) * log2(n), the random bits in one code |
interface Token {
readonly : string;
readonly : number;
readonly : number;
readonly : number;
readonly : string;
readonly : number;
(?: GenerateOptions): GenerateResult;
(: string): ;
}
const = ({ : '0123456789abcdef', : 16 });
.; // -> 16
.; // -> 60
.()..; // -> 19The shape and alphabet createToken builds a token from. Every field is
optional, and createToken checks all four once, at construction.
| Option | Default | Constraint |
|---|---|---|
length | DEFAULT_LENGTH | an integer of at least 2, the check character included |
chunkSize | DEFAULT_CHUNK_SIZE | an integer of at least 1 that divides length |
separator | DEFAULT_SEPARATOR | non-empty, and sharing no character with dictionary |
dictionary | DEFAULT_DICTIONARY | the four constraints under InvalidAlphabetError |
interface TokenOptions {
?: number;
?: number;
?: string;
?: string;
}
const = ({ : 6, : 3, : ' ' });
const { } = .();
.; // -> 7
.(3); // -> ' '
.().; // -> true
.(.(' ', '-')).; // -> falseOptions generate takes per call.
prefix is not checked against the dictionary and is not part of the check
character.
interface GenerateOptions {
?: string;
}
const = ();
const { } = .({ : 'ORD' });
.(); // -> { valid: false, reason: 'outside-alphabet' }
/** Validates a pickup code read back with or without its `ORD` prefix. */
function (: string) {
return .(.(/^ord-?/i, ''));
}
().; // -> true
(.()).; // -> true
(.('-', '')).; // -> trueA freshly minted code, in the two forms a caller needs.
value is the chunked code, preceded by prefix and separator when a prefix
is given. body is what to store and index on.
interface GenerateResult {
: string;
: string;
: string;
: string | undefined;
}
const = ();
const = .({ : 'ORD' });
.; // -> 'ORD'
..; // -> 7
..; // -> 13
..(.); // -> trueWhat validate returns. Narrow on valid: true carries the body to look
up, and false carries the reason the code failed first.
validate strips the separator and folds case before it checks, so body is
the same string whatever grouping or case the input used.
export type = ValidToken | InvalidToken;const = ();
const = new ([['a4kp9mx', 'order-2026-0042']]);
function (: string): string {
const = .();
if (!.) return `refused: ${.}`;
return .(.) ?? 'no such order';
}
('A4KP9MXA'); // -> 'order-2026-0042'
('a4kp-9mx8'); // -> 'refused: check-failed'
(.().); // -> 'no such order'A code that is well formed. It does not follow that the code exists.
ValidToken is the valid: true half of ValidateResult.
interface ValidToken {
: true;
: string;
}
A code that is not well formed, and the first constraint it failed.
InvalidToken is the valid: false half of ValidateResult.
interface InvalidToken {
: false;
: ;
}
Why a code is not well formed.
validate checks in the order the union lists and reports the first failure. A
prefix is not part of a code: validate reads a leading ORD- as three more
characters, and its o is outside-alphabet.
export type = 'outside-alphabet' | 'wrong-length' | 'check-failed';const = ();
.('b0zg-7kqb'); // -> { valid: true, body: 'b0zg7kq' }
.('b0zg-7kq8'); // -> { valid: false, reason: 'check-failed' }
.('bozg-7kqb'); // -> { valid: false, reason: 'outside-alphabet' }
.('b0zg-7kq'); // -> { valid: false, reason: 'wrong-length' }32 characters: the lowercase alphanumerics without i, l, o and w.
Full documentation
The size is the constraint, and it comes first. 32 is even and free of case
pairs (a letter alongside its uppercase form), so @evanion/luhn can
compute a check character over it, and it
divides 256, so byte % 32 draws every character with equal probability.
Any other count either biases the draw or cannot carry a check character.
That fixes how many characters leave: four. Three pick themselves, because
they are the pairs a person actually confuses — i and l read as 1, and
o reads as 0. In each pair the letter goes and the digit stays, because
a code is as often typed as it is spoken, and a digit reads unambiguously on
a keypad.
w is the fourth, and it is the weakest of the four. Dropping only the
three leaves 33, which is odd and does not divide 256, so a fourth has to
go whatever the reason. w is the candidate with an argument attached: it
is the one English letter whose name is polysyllabic and contains another
letter’s name, so “double-u” dictated down a phone can be written back as
u. Nobody mistakes w for another character on a screen.
export declare const = "0123456789abcdefghjkmnpqrstuvxyz";import { , } from '@evanion/token';
; // -> '0123456789abcdefghjkmnpqrstuvxyz'
.; // -> 32
; // -> 'ilow'Characters excluded from every token alphabet, matched without regard to case.
Full documentation
1 and 0 survive their groups because a code is as often typed as it is
spoken, and a digit reads unambiguously on a keypad.
CONFUSABLE_CHARACTERS is 'ilow'. createToken refuses a dictionary holding
any of the four, in either case.
export declare const = "ilow";Total characters in a token, the check character included.
export declare const = 8;Characters between separators. Divides DEFAULT_LENGTH exactly.
export declare const = 4;Placed between chunks, and between a prefix and the code.
export declare const = "-";Base class for every error this library throws. All of them are raised by
createToken: generate and validate are total, so an accepted
configuration cannot fail at use.
Full documentation
A dictionary that fails one of Luhn’s own constraints throws
InvalidDictionaryError from @evanion/luhn instead, which does not extend
this class. Catch the two by name, or catch Error to cover both.
InvalidAlphabetError and InvalidShapeError extend TokenError.
export declare class extends {
constructor(: string);
}What the tests state
1 behaviour
TokenError
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';
import { , } from '@evanion/token';
/** The name of the error `createToken` throws for `dictionary`. */
function (: string): string {
try {
({ });
} catch () {
if ( instanceof ) return .;
if ( instanceof ) return .;
throw ;
}
return 'accepted';
}
('0123456789abcdefghijklmnopqrstuvwxyz'); // -> 'InvalidAlphabetError'
('0123456789abcdefghjkmnpqrstuvxy'); // -> 'InvalidDictionaryError'
('0123456789abcdef'); // -> 'accepted'Thrown by createToken for the dictionary constraints token owns: no
character of CONFUSABLE_CHARACTERS, no uppercase character, which
validate folds away before it looks one up, and a size that divides 256, so
that byte % n draws every character equally often.
Full documentation
The remaining constraints — even size, no repeats, no case pairs — are
Luhn’s, and @evanion/luhn reports them as its own InvalidDictionaryError.
One class carrying a reason rather than one class per constraint, so a
caller that only wants to know whether an alphabet is acceptable writes one
catch arm.
| Property | What it is |
|---|---|
reason | an InvalidDictionaryReason |
dictionary | the dictionary as supplied |
offending | the characters named; empty for non-uniform, which is about the size |
| Constraint | Thrown by |
|---|---|
| no confusable characters | InvalidAlphabetError, reason: 'confusable' |
| lowercase | InvalidAlphabetError, reason: 'unfolded' |
256 % n === 0 | InvalidAlphabetError, reason: 'non-uniform' |
| even size, no repeats, no case pairs | InvalidDictionaryError from @evanion/luhn |
export declare class extends {
readonly : ;
readonly : string;
readonly : readonly string[];
constructor(: , : string, ?: readonly string[]);
}What the tests state
5 behaviours
InvalidAlphabetError
is a TokenError carrying 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/token';
/** The alphabet error `createToken` throws for `dictionary`, if it throws one. */
function (
: string,
): | undefined {
try {
({ });
} catch () {
if ( instanceof ) return ;
throw ;
}
return ;
}
const = '0123456789abcdefghijklmnopqrstuvwxyz';
()?.; // -> 'confusable'
()?.; // -> ['i', 'l', 'o', 'w']
('0123456789abcdefghjkmnpqrstuvx')?.; // -> 'non-uniform'
('0123456789ABCDEFGHJKMNPQRSTUVXYZ')?.; // -> 'unfolded'
('0123456789abcdef'); // -> undefinedWhich of token’s own dictionary constraints the alphabet failed, as
InvalidAlphabetError carries it. InvalidDictionaryError is
@evanion/luhn’s class and carries Luhn’s reasons, not these.
reason | The dictionary |
|---|---|
confusable | holds a character of CONFUSABLE_CHARACTERS |
unfolded | holds an uppercase character |
non-uniform | has a size that does not divide 256 |
export type = 'confusable' | 'unfolded' | 'non-uniform';Thrown by createToken when length, chunkSize or separator cannot
describe a token.
Full documentation
It carries all three rather than only the offending one: the constraints are
relations between them, so the value that has to change is not always the
value the reason names.
| Property | What it is |
|---|---|
reason | an InvalidShapeReason |
length, chunkSize, separator | all three as resolved, defaults included |
export declare class extends {
readonly : ;
readonly : number;
readonly : number;
readonly : string;
constructor(: , : TokenShape);
}What the tests state
3 behaviours
InvalidShapeError
is a TokenError carrying 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/token';
import type { TokenOptions } from '@evanion/token';
/** The shape error `createToken` throws for `options`, if it throws one. */
function (: TokenOptions): | undefined {
try {
();
} catch () {
if ( instanceof ) return ;
throw ;
}
return ;
}
({ : 10, : 5 }); // -> undefined
({ : 10, : 4 })?.; // -> 'chunk-size-indivisible'
({ : 10, : 4 })?.; // -> 'Token chunkSize must divide length, or the last chunk is shorter than the rest; 4 does not divide 10.'
({ : 12, : 4 }); // -> undefined
({ : '0123456789abcde-' })?.; // -> 'separator-in-dictionary'
({ : 'a' })?.; // -> 'separator-in-dictionary'
({ : 'a' })?.; // -> 8Which of the three shape options createToken rejected, and why.
reason | The shape |
|---|---|
length | length is not an integer, or is below 2 |
chunk-size | chunkSize is not an integer, or is below 1 |
chunk-size-indivisible | chunkSize does not divide length |
separator-empty | separator is the empty string |
separator-in-dictionary | separator shares a character with dictionary |
export type = 'length' | 'chunk-size' | 'chunk-size-indivisible' | 'separator-empty' | 'separator-in-dictionary';