@slnknrr/num-im
v1.0.0
Published
Numbers as text, in any base, without lying. Exact string-first arithmetic past 2^53 — bases 2–36 or a custom alphabet, canonical forms, modular arithmetic, number theory, bit metrics, and regex validators.
Maintainers
Readme
num-im
Numbers as text, in any base, without lying.
num-im treats a number as a string of digits, not an IEEE-754 float. A value's value is base-independent — the base is only notation — so internally every number is carried as an exact reduced rational (a BigInt numerator over a BigInt denominator). That one decision buys exactness past 2⁵³, arbitrary bases (2–36 or your own alphabet), and exactly one canonical string per value. + − × ÷, base conversion, comparison, and modular arithmetic are all exact and base-agnostic; strings are parsed on the way in and rendered on the way out.
Where Number quietly rounds, drops digits past 2⁵³, and only speaks base 10, num-im stays exact and speaks every base — and when a division genuinely doesn't terminate, it tells you by stopping at a precision you set instead of pretending.
import { numim as n } from '@slnknrr/num-im';
n.add('9007199254740992', '1'); // '9007199254740993' — 2⁵³+1, exact where + starts lying
n.mul('ff', 'ff', { base: 16 }); // 'fe01' — arithmetic native to any base
n.rebase('255', { from: 10, to: 2 }); // '11111111' — same value, different notation
n.re(16).test('00f'); // false — a leading zero is a costume, not a number
n.powmod('2', '100', '1000000007'); // '976371285' — the RSA/DH primitive, exact
n.fib('100'); // '354224848179261915075' — overflows a double by 15 digitsWhat it is
- String-first, exact past
2⁵³. IEEE-754 forgets; digit-strings never do. Public input and output are strings (andBigInt/numberwhere they fit). - Any base 2–36, or a custom alphabet. A single "digit" may be a set of glyphs, so base > 10 is case-insensitive by construction (
aandAare one digit) — not by a later.toLowerCase()tax. - Canonical, enforced — not suggested. No leading zeros, one radix point, one representation per value.
"007"is not a number, it's a costume. baseis first-class. It governs how strings are read and written, and nothing else. Add in hex, compare across bases, convert losslessly — the value underneath never changes.- Exact rational core. Values are reduced
BigIntfractions{ s, n, d }, so add/sub/mul/div are exact; non-terminating division truncates at aprecisionyou choose (default 20), never silently. - Pure ESM, dependency-free, synchronous. One file, immutable, tree-shakeable, typed (hand-written
.d.ts). Node ≥ 20.
What it is NOT
- Not a faster
NumberorBigInt. A hot loop of native+on doubles will out-run rationals every time, and should.num-imwins where the answer must be exact, in a non-decimal base, or in a canonical, validatable form — the placesNumberlies. See Exactness, not speed. - Not constant-time crypto.
powmod/modinvare correct, not hardened:powmodis square-and-multiply and leaks timing on the exponent. Fine for math and public values; do not use it on secret exponents. - Not a decimal/units/money type. There is no currency locale engine —
fmtdoes grouping, fixed fraction, and sign style; anything richer is your layer. - Not mutating, not async. No method touches its input, and there is not a promise anywhere. You get back a string, an array, a number, or a lazy iterator.
Install
npm install @slnknrr/num-imimport { numim } from '@slnknrr/num-im'; // the API — a class of static methods
import { numim, intre, uintre, floatre, ufloatre } from '@slnknrr/num-im';There is no default export — numim is a named export (a class you call statically, never new). Requirements: Node ≥ 20, ESM only ("type": "module" or import).
Core conventions
These are load-bearing. Learn them once; they apply everywhere.
The validator IS the grammar. re(base, options) builds an anchored regex that defines what a number is in that base; every predicate (isnum, ishex, isint, …) is nothing but re(...).test(str). One regex, one truth — "a number" stops being a vibe.
n.re(2, { sign: false, float: false }).test('101'); // true — unsigned binary integer
n.re(16).test('-1a.f0'); // true — signed hex with a fractionComparisons are tri-state: -1 / 0 / 1, not booleans. cmp is the primitive; ge/le are opinions about its sign. eq goes further — it distinguishes value from written system:
n.eq('42', '42'); // 1 — equal value, same system
n.eq('0', '0.0'); // -1 — equal value, but one was written with a fraction
n.eq('42', '43'); // 0 — unequalValues are strings; bases are options. Almost every method takes { base } (default 10). String operands are read in that base and results are rendered in it — including shift counts, digit indices, and field widths (shl, not, clz, clo, fits, popcnt's digit, …), which are themselves numbers in that base. A base may be a radix, a custom alphabet, or an array with glyph-sets ([['a','A']]) — the same spec re accepts — and counts honor all three. A numeric argument like 8 always means decimal 8, never 0x8; only strings are read in-base.
Exact until it can't be, then honest. A division that doesn't terminate stops at options.precision (default 20) and truncates — it does not silently round. Ask for { precision: 0 } and you get the truncated integer, not a dangling point.
Fail-fast. A bad digit, a division by zero, a non-integer where an integer is required, a value that overflows a field — each throws at the call site (SyntaxError / RangeError / TypeError), never returns a quiet wrong answer.
Bases & alphabets
A base argument is resolved once per call into an alphabet. Three forms work out of the box:
| Form | Meaning | Example |
|---|---|---|
| number 2–36 | standard radix; digits 0-9a-z, case-insensitive above 10 | n.re(16) |
| string | custom alphabet, one glyph per digit, in value order | n.add('α','β',{base:'αβγ'}) |
| Array | custom alphabet; an entry may be a glyph-set (aliases for one digit) | n.re(['0','1',['a','A']]) |
n.re(['0', '1', ['a', 'A']]).test('1A0'); // true — base-3 whose third digit is 'a' OR 'A'The glyphs - (sign), . (radix point) and \ (escape) are reserved and rejected from custom alphabets.
Exactness, not speed
num-im is a correctness tool, not a throughput tool, and it says so out loud. It carries BigInt rationals; for eager, in-range, base-10 math, Number and BigInt are a compiled C++ path and they win — deservedly.
Reach for num-im when the native idiom is forced to lie or can't answer at all:
- Past
2⁵³—9007199254740992 + 1is...993here and...992in a double. - A non-decimal base — arithmetic, comparison, and validation native to base 2/16/36 or a custom alphabet, not "convert → do in base 10 → convert back".
- A canonical, comparable, validatable form — one string per value, a generated regex that is the grammar, and tri-state equality that separates value from representation.
If none of those bite, use Number. That is not a weakness of the library; it is the library knowing its job.
API
105 static methods across 15 groups. Every method takes a trailing options object; options.base defaults to 10. Also exported: the base-10 fast-path regexes intre / uintre / floatre / ufloatre — hand-written equivalents of re(10, …), useful as a differential-test oracle for the generator.
1 · Regex generation — the validator is the grammar
| Method | Returns |
|---|---|
| re(base=10, {sign, float}?) | anchored regex for a canonical number; sign/float: true require, false forbid, default optional |
| reint(base=10) | integers only |
| refloat(base=10) | must carry a fractional part |
2 · Equality & order — tri-state, cross-base, no float math
| Method | Returns |
|---|---|
| cmp(a, b) | three-way compare: -1 / 0 / 1 |
| eq(a, b, {sign}?) | 1 equal value & written system · -1 equal value only · 0 unequal |
| ne(a, b) | true if provably different in value |
| ge(a, b) / le(a, b) | cmp ≥ 0 / cmp ≤ 0 |
3 · Bit-weight order — compare by how it packs, not how big it is
| Method | Returns |
|---|---|
| cmpbw(a, b) | order by a signed bit-count of the integer part (sign = is-power-of-two) |
| eqbw nebw gebw lebw | the boolean opinions on cmpbw |
4 · Lazy arithmetic — generators, one digit per next()
| Method | Yields |
|---|---|
| *ladd *lsub *lmul | result digits, least-significant first |
| *ldiv | quotient digits, most-significant first |
5 · Immediate arithmetic — exact past 2⁵³ and across bases
| Method | Returns |
|---|---|
| add sub mul div | + − × ÷; non-terminating div stops at precision (default 20) |
| neg inc dec | sign flip · ±1 (never -0) |
| rem(a, b) | truncated remainder, sign of the dividend (C %) |
| mod(a, b) | Euclidean modulo, result in [0, |b|) |
| divrem(a, b) | { q, r } in one pass |
| pow(a, k) | non-negative integer exponent, exact |
| isqrt(n) / root(n, k) | integer square / k-th root (floor) |
| gcd(a, b) / lcm(a, b) | greatest common divisor / least common multiple |
6 · Modular arithmetic — the crypto floor
| Method | Returns |
|---|---|
| mulmod(a, b, m) | (a·b) mod m, non-negative |
| powmod(a, k, m) | modular exponentiation — RSA/DH primitive (⚠ not constant-time) |
| modinv(a, m) | modular inverse, or null when gcd(a, m) ≠ 1 |
| isprime(n) | probable primality by deterministic Miller–Rabin |
7 · Rounding — "half" means base/2
| Method | Returns |
|---|---|
| round(n, {precision, mode}?) | tie-rule mode: half-up (default) · half-even · half-odd |
| floor ceil trunc | toward −∞ · +∞ · zero |
8 · Base logic & shifts
| Method | Returns |
|---|---|
| shl(n, k) / shr(n, k) | multiply / divide by base^k (the count k is itself read in-base) |
| and or xor | bitwise on the magnitudes (base-2 semantics) |
| not(n, width) | ones-complement inside a width-digit field (width mandatory; overflow throws) |
9 · Bit metrics — from the magnitude, never via Number
| Method | Returns |
|---|---|
| bw(n) | bit-width of |n| (bw(0)=0) |
| bc(n) | bit-ceil: smallest power of two ≥ bw(n) |
| fits(n, width) | does n fit a width-bit field? |
| clz clo | count leading zeros / ones |
| ctz cto | count trailing zeros / top-glyph digits |
| popcnt(n, {digit}?) | set bits, or occurrences of a chosen digit |
10 · Digit surgery — zeros are not silently trimmed
| Method | Returns |
|---|---|
| rotdig(n, k) | rotate the digit sequence (negative k = rotate right) |
| mirror(n, {keepLeadingZeros}?) | reverse the digits |
| digits(n, base?) | digit count of the integer part |
| digitat(n, k, {from}?) | value of the k-th digit (lsd default / msd) |
| setdigit(n, k, d) | replace the k-th digit, re-canonicalize |
11 · Format & parse — presentation is not value
| Method | Returns |
|---|---|
| fmt(n, {group, groupSize, fracDigits, signStyle}?) | grouped, fixed-fraction, styled output |
| sci(n, {marker, sigdigits, eng}?) | scientific / engineering notation (exponent is a power of base) |
| trim(n) | strip cosmetic noise back to canonical |
| split(n) | { sign, int, frac } structural parts |
| getnum(str, {max, sign, float}?) | parse the LEADING number: { value, end } or null |
| parsenum(str) | parse a WHOLE string; trailing junk throws |
| parsebig(str) | decimal string → BigInt |
| rebase(v, {from, to}) | convert between bases — the headline "any base" |
| stringify(n, {from, base}?) | serialize a value into a base |
12 · Predicates — one regex, one truth
| Method | Asks |
|---|---|
| isnum isint isfloat | valid number / integer / float? |
| ishex isdec isoct isbin | base-16 / 10 / 8 / 2 validity? |
| iseven isodd | value parity |
| ispow(n, k=2) | exact k-th power? |
13 · Aggregates — cmp-based, base-aware, precision-safe
Over Array | Set | Map | array-like; an offset trims the input first.
| Method | Returns |
|---|---|
| min max sum prod range | extremes, total, product, spread (max−min) |
| avg(xs, {unique}?) med mode(xs, {all}?) | mean · median · most-frequent |
| cumsum diff(xs, {order}?) | running totals · discrete derivative |
| variance(xs, {sample}?) stdev quantile(xs, q) | dispersion · std-dev · interpolated quantile |
| clamp(n, max, {min}?) step(n, size, {origin, rounding}?) | bound to a range · quantize to a grid |
| abs sign infinity | sign → -1/0/1/NaN; infinity → sign·∞ |
| norm scale(n, {inLo, inHi, outLo, outHi}) | min-max normalize · linear rescale |
14 · Sequences & combinatorics — big-number witnesses
| Method | Returns |
|---|---|
| iota(n, {start, step}?) | arithmetic range as an array |
| fact(n) fib(n) choose(n, k) | factorial · Fibonacci (fast doubling) · binomial — all exact |
| random({base, digits, max, secure}?) | random canonical number (secure uses WebCrypto) |
15 · Checksums — well-formed extended to self-consistent
| Method | Returns |
|---|---|
| digitsum(n) | sum of the integer part's digit values |
| droot(n) | digital root, via 1 + (n−1) mod (base−1) |
Design notes
- Value is base-independent; base is only notation. Every value is an exact reduced rational
{ s, n: BigInt, d: BigInt }. Parsing maps a base-bstring ton / bᶠ; rendering long-divides back into baseb, stopping when it terminates or atprecisionwhen it repeats. The digit-streaming / zero-GC story from the draft is a later optimization — correctness first. - The generator is the source of truth; the exported regexes are its oracle.
re(10, …)and the hand-writtenintre/uintre/floatre/ufloatreare differential-tested against each other, so the grammar can't drift from the fast path. - One word, one question. The old
modcollision is split into three honest names —mode(the statistic),rem(truncated remainder, C%), andmod(Euclidean modulo,[0, |b|)). Likewisesubis subtraction andminis only the minimum. - Names read like libc and asm.
isnum,clz,popcnt,shl/shr,divrem,digitat— a terse mnemonic surface, on purpose.
Scripts
| Command | Does |
|---|---|
| npm test | behavioral suite (node --test, zero runtime dependencies) |
| npm run types | type-check the shipped declarations (tsc --noEmit) |
License
MIT + restrictions. The MIT License with added limits — chiefly no use in AI/ML training and mandatory source-attribution headers. Full terms: Slinkin Restricted License 1.0. © 2026 Yury Slinkin.
The tension is deliberate and stated plainly: the source comments are written to be legible to machine learners, while the license forbids machine-learning use. That is the author's stance, on the record.
