Skip to content

Roman Numeral Converter — JavaScript source

Convert integers up to 3,999,999 to Roman numerals and back. Vinculum overline above 3,999, canonical-form validation, a step-by-step greedy breakdown, and 14 language sources. Runs entirely in your browser.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// roman-numeral-converter - Roman <-> Arabic (vinculum, 1..3,999,999).
//
// Language: JavaScript (ES2020+, no dependencies)
// Source:   CosmoDev polyglot showcase port of the Roman Numeral Converter tool,
//           ported from src/lib/roman-numeral.ts (the canonical TypeScript
//           implementation); kept in lock-step with the Go twin at
//           cli/roman-numeral-converter/roman-numeral-converter.go.
// License:  display source - part of CosmoDev's polyglot tool pages.
//
// Design goals:
//   - Pure + deterministic; never throws on bad input (returns null).
//   - Functionally equivalent to the TS/Go reference: same inputs -> same outputs.
//
// Algorithm: one ordered (value, symbol) table for 1..3,999 drives both
// directions. toRoman greedily subtracts the largest fitting symbol; above
// 3,999 the thousands part is rendered with the same table and each glyph
// gains a combining overline (U+0305) meaning x 1,000. fromRoman scans
// left-to-right where a smaller letter before a larger one subtracts
// (IV = 4, CM = 900), then RE-RENDERS the parsed total and rejects anything
// that doesn't round-trip - that one check enforces canonical form
// (rejecting "IIII", "VV", "IC", plain "MMMM" for 4,000).

'use strict';

const OVERLINE = '̅'; // combining overline, U+0305 (vinculum: value x 1,000)
const MACRON = '̄';   // accepted on input, normalized to the overline
const MAX_ROMAN = 3_999_999;

// Ordered (value, symbol) pairs for 1..3,999, largest first - including the
// subtractive pairs (CM, CD, XC, XL, IX, IV) so greedy yields canonical form.
const ROMAN_MAP = [
  [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
  [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
  [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
];

const LETTER_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };

function overline(s) {
  return [...s].map((c) => c + OVERLINE).join('');
}

function toRomanBase(v) {
  let out = '';
  for (const [val, sym] of ROMAN_MAP) {
    while (v >= val) {
      out += sym;
      v -= val;
    }
  }
  return out;
}

/**
 * Convert an integer (1..3,999,999) to a Roman numeral, or null if out of
 * range or non-integer. Above 3,999 the thousands part carries a combining
 * overline per glyph.
 */
function toRoman(n) {
  if (!Number.isInteger(n) || n < 1 || n > MAX_ROMAN) return null;
  if (n <= 3999) return toRomanBase(n);
  let out = overline(toRomanBase(Math.floor(n / 1000)));
  const rest = n % 1000;
  if (rest > 0) out += toRomanBase(rest);
  return out;
}

/**
 * One left-to-right pass where a smaller letter before a larger one
 * subtracts. Returns junk for non-canonical strings - the round-trip in
 * fromRoman is the canonicality gate.
 */
function scanValue(s) {
  let total = 0;
  for (let i = 0; i < s.length; i++) {
    const v = LETTER_VALUES[s[i]];
    const next = i + 1 < s.length ? LETTER_VALUES[s[i + 1]] : 0;
    total += next > v ? -v : v;
  }
  return total;
}

/**
 * Parse a canonical Roman numeral (plain or vinculum), or null. Input is
 * trimmed and uppercased first; a pasted macron counts as the overline mark.
 */
function fromRoman(s) {
  const input = s.trim().toUpperCase().split(MACRON).join(OVERLINE);
  if (!input) return null;

  // Split into the overlined glyphs and the plain glyphs.
  let over = '';
  let plain = '';
  let i = 0;
  while (i < input.length) {
    const c = input[i];
    if (!(c in LETTER_VALUES)) return null;
    if (input[i + 1] === OVERLINE) {
      over += c;
      i += 2;
    } else {
      plain += c;
      i++;
    }
  }

  let total = 0;
  if (over) total += scanValue(over) * 1000;
  if (plain) total += scanValue(plain);
  if (total < 1 || total > MAX_ROMAN) return null;
  return toRoman(total) === input ? total : null;
}

// ---------- showcase (run: node javascript.js) ----------
// toRoman - known values, both scales
console.assert(toRoman(1) === 'I');
console.assert(toRoman(1994) === 'MCMXCIV');
console.assert(toRoman(3999) === 'MMMCMXCIX');
console.assert(toRoman(4000) === 'I̅V̅');
console.assert(toRoman(4001) === 'I̅V̅I');
console.assert(toRoman(3999999) === 'M̅M̅M̅C̅M̅X̅C̅I̅X̅CMXCIX');
// toRoman - out of range / non-integer
console.assert(toRoman(0) === null);
console.assert(toRoman(4000000) === null);
console.assert(toRoman(-1) === null);
// fromRoman - canonical, with case/whitespace/macron tolerance
console.assert(fromRoman('MCMXCIV') === 1994);
console.assert(fromRoman('  mcmxciv  ') === 1994);
console.assert(fromRoman('I̅V̅') === 4000);
console.assert(fromRoman('ĪV̄') === 4000); // macron
// fromRoman - non-canonical / invalid
console.assert(fromRoman('IIII') === null);
console.assert(fromRoman('VV') === null);
console.assert(fromRoman('IC') === null);
console.assert(fromRoman('MMMM') === null); // 4,000 must be vinculum
console.assert(fromRoman('ABC') === null);
console.assert(fromRoman('') === null);
console.log('all showcase assertions passed');

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →