I built a base converter this week and my first instinct was that it needed clever math. It doesn't. A "number in base 16" and "the same number in base 2" aren't two different numbers — they're two spellings of one value, and the base only decides the digit alphabet and what each position is worth. So converting is two honest steps: read a string into one exact value, then write that value out in whatever base you asked for. Two pure functions, a guard, and one decision that actually matters. Here's the whole thing.
A digit's value is pure charCode arithmetic
Every base needs to know what one character is worth. '0'–'9' map to 0–9 and 'a'–'z' continue 10–35 — which is why base-36 is the natural ceiling (10 digits + 26 letters). Return -1 for anything that isn't a digit; the caller uses that to reject bad input.
function digitVal(ch){
const c = ch.charCodeAt(0);
if (c >= 48 && c <= 57) return c - 48; // '0'..'9' -> 0..9
if (c >= 97 && c <= 122) return c - 97 + 10; // 'a'..'z' -> 10..35
return -1; // not a digit
}
Read a string with Horner's method
Parsing base-b is one line repeated: start at 0, and for each digit do acc = acc × b + digit. That's the entire positional-notation formula, evaluated left to right without ever computing a power. It validates as it goes — a digit < 0 or >= base isn't legal — and the critical choice is that acc is a BigInt, so it never rounds.
const B = BigInt(base);
let acc = 0n; // BigInt, not 0
for (const ch of s){
const d = digitVal(ch);
if (d < 0 || d >= base) return { ok:false, error:`'${ch}' isn't base-${base}` };
acc = acc * B + BigInt(d); // Horner step
}
Write it back out with repeated division
The inverse of Horner: value modulo the base gives the last digit, integer-divide the base away, repeat until zero — collecting digits least-significant first, so prepend each one. Every operation stays in BigInt, so a 200-digit number is exactly as easy as 255.
while (v > 0n){
out = DIGITS[Number(v % B)] + out; // remainder = next digit (prepend)
v = v / B; // BigInt division floors toward 0
}
Why BigInt — the whole reason this tool exists
A JavaScript number is a 64-bit float. It holds integers exactly only up to 2⁵³; past that, consecutive integers start sharing one float and parseInt silently rounds. This is not a corner case — it's every 64-bit ID and hash you'll ever paste in.
parseInt("9007199254740993") // 9007199254740992 off by one
Number("18446744073709551615") // 18446744073709552000 rounded
BigInt("9007199254740993") // 9007199254740993n exact
That one substitution is the difference between a toy and a correct converter.
One value, N views
The UI has no special logic. There's exactly one source of truth — the current BigInt — and every field is a view of it. Typing in a field parses its text in its own base; on success it becomes the new value and every other field re-renders with toBase. Skip the active field so the cursor doesn't jump; an invalid entry just marks that field red and leaves the value untouched.
function onEdit(field){
const r = parseInBase(field.input.value, field.base);
if (!r.ok){ markInvalid(field, r.error); return; }
current = r.value;
for (const other of FIELDS)
if (other !== field) other.input.value = toBase(current, other.base);
}
The arbitrary-base picker (2–36) needed no new code, because the engine was general from the start — base 10 and 16 were never hard-coded. Grouping into nibbles and bytes, the fixed-width fit check, and two's-complement (BigInt.asUintN(bits, v) gives you 0xFFFFFFD6 for −42) are thin garnish on top. The lesson underneath: find the single source of truth and make every view a pure function of it.
Type in any field and watch the rest re-spell the same value:
https://dev48v.infy.uk/solve/day47-number-base-converter.html
Top comments (0)