DEV Community

SEN LLC
SEN LLC

Posted on

Building a Base Converter — BigInt for Unlimited Digits, and Detecting Repeating Fractions

A converter between any radix from 2 to 36. "Isn't that just parseInt(s, 16)?" — done properly, there are two traps: (1) Number loses precision past 2^53, so large values need BigInt, and (2) a fraction that terminates in one base often repeats in another (decimal 0.1 is 0.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

Screenshot

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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 = binary 0.0001100110011... (0011 repeats forever)
  • decimal 0.5 = binary 0.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 };
}
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 BigIntNumber corrupts past 2^53. Only the v % base index is safe to Number() (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/den BigInt 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)