A converter between any radix from 2 to 36. "Isn't that just
parseInt(s, 16)?" — done properly, there are two traps: (1)Numberloses precision past 2^53, so large values need BigInt, and (2) a fraction that terminates in one base often repeats in another (decimal0.1is0.0(0011)repeating in binary). How you detect and display that repetition is the real story. Fully in-browser, vanilla JS.
🌐 Demo: https://sen.ltd/portfolio/base-converter/
📦 GitHub: https://github.com/sen-ltd/base-converter
Hinge 1: BigInt integer part, no precision ceiling
parseInt("ff", 16) does return 255. But large values break:
Number.parseInt("ffffffffffffffff", 16) // 18446744073709552000 (wrong!)
// correct: 18446744073709551615
Number is float64 — past 2^53 it can't represent integers exactly, which is fatal for a base converter. Use BigInt:
const DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz";
export function parseIntPart(intStr, base) { // string → BigInt
const b = BigInt(base);
let acc = 0n;
for (const ch of intStr) acc = acc * b + BigInt(digitValue(ch));
return acc;
}
export function renderIntPart(value, base) { // BigInt → string
if (value === 0n) return "0";
const b = BigInt(base);
let out = "", v = value;
while (v > 0n) { out = DIGITS[Number(v % b)] + out; v = v / b; }
return out;
}
The digit ceiling disappears:
test("200-bit number converts exactly", () => {
const bin = "1" + "0".repeat(200); // 2^200
assert.equal(renderIntPart(parseIntPart(bin, 2), 10),
"1606938044258990275541962092341162602522202993782792835301376");
});
v % b is Number()-cast for indexing, which is safe because b ≤ 36 so the remainder is always 0–35.
Hinge 2: detecting repeating fractions
The interesting part. A fraction that "terminates" in one base "repeats" in another.
- decimal
0.1= binary0.0001100110011...(0011repeats forever) - decimal
0.5= binary0.1(terminates)
Why? 0.1 = 1/10. The denominator 10 = 2×5 has a factor of 5, but binary only terminates when the denominator is a power of 2. The leftover 5 makes it repeat.
Detection: remember the remainders
Fractional conversion is "multiply by the target base, take the integer part, repeat." When a remainder recurs, the digits since then repeat — the same trick as long division:
export function convertFraction(fracStr, fromBase, toBase, maxDigits = 64) {
// hold the fraction exactly as num/den (BigInt)
const fb = BigInt(fromBase);
let num = 0n, den = 1n;
for (const ch of fracStr) {
num = num * fb + BigInt(digitValue(ch));
den = den * fb;
}
const g = gcd(num, den); // reduce → canonical remainders
if (g > 0n) { num /= g; den /= g; }
const tb = BigInt(toBase);
const digits = [];
const seen = new Map(); // remainder → digit index
let rem = num, repeatStart = -1;
while (rem !== 0n && digits.length < maxDigits) {
if (seen.has(rem)) { // seen before = cycle starts
repeatStart = seen.get(rem);
break;
}
seen.set(rem, digits.length);
rem *= tb;
digits.push(DIGITS[Number(rem / den)]); // next digit
rem %= den;
}
return { digits: digits.join(""), repeatStart };
}
The key is holding the fraction as an exact num/den over BigInt. Float arithmetic would introduce drift and break the cycle detection; exact BigInt remainders match precisely, so the repetend is found reliably.
test("0.1 dec → repeating binary, wrapped in parens", () => {
assert.deepEqual(convert("0.1", 10, 2), { value: "0.0(0011)", repeating: true });
});
In the demo, decimal 0.1 shows as binary 0.0(0011), octal 0.0(6314), hex 0.1(9) — each base repeats differently, visible at a glance.
Why reduce the fraction first
The gcd reduction at the top canonicalizes the remainders. Without it, the same value can appear as 2/20 and 1/10, and remainder-based cycle detection can misfire. Reduce to lowest terms first and each remainder is unique to its value.
Input validation
In base N the valid digits are 0 through the (N-1)th character. A 2 in a binary number is invalid:
export function isValidForBase(str, base) {
const body = str.startsWith("-") ? str.slice(1) : str;
if (body === "" || body === ".") return false;
let seenDot = false;
for (const ch of body) {
if (ch === ".") { if (seenDot) return false; seenDot = true; continue; }
const v = digitValue(ch);
if (v < 0 || v >= base) return false;
}
return true;
}
The UI reports the exact allowed digits on error.
Architecture
convert.js — parse/render (BigInt), fraction conversion + cycle detection (DOM-free, 38 tests)
app.js — UI glue, common-base comparison table
Bases 2–36 (0-9a-z), negatives, fractions. convert.js is DOM-free, so all 38 tests run in Node.
Try it
Enter 0.1 and switch the output base between 2 / 8 / 16 — every base repeats, with a different repetend. Enter a 2^100-scale integer and watch the precision hold.
Takeaways
- Do the integer part in BigInt —
Numbercorrupts past 2^53. Only thev % baseindex is safe toNumber()(base ≤ 36). - A fraction that terminates in one base can repeat in another — when the denominator keeps a prime factor the target base lacks.
- Detect repetition via recurring remainders in a
Map— the long-division trick. - Hold the fraction as an exact
num/denBigInt so there's no float drift in cycle detection. - gcd-reduce first to canonicalize remainders and keep detection stable.
This is OSS portfolio #269 from SEN LLC (Tokyo). https://sen.ltd/portfolio/

Top comments (0)