DEV Community

SEN LLC
SEN LLC

Posted on

A Roman Numeral Converter With Step-By-Step Breakdown and Strict Validation

A Roman Numeral Converter With Step-By-Step Breakdown and Strict Validation

Roman-to-Arabic is a greedy subtraction: find the largest Roman value that fits, subtract, repeat. Arabic-to-Roman has two tricky parts: the subtractive forms (IV, IX, XL, XC, CD, CM) and the fact that IIII is "valid" in ancient inscriptions but invalid in standard notation. A correct validator enforces the modern rules.

Roman numerals are the kind of thing everyone half-remembers. Does 1990 end in XC or IC? Is IIII allowed? Is MMMM valid or do you need a vinculum for 4000+? The answers are subtle and the validator needs to know them.

🔗 Live demo: https://sen.ltd/portfolio/roman-numeral/
ðŸ“Ķ GitHub: https://github.com/sen-ltd/roman-numeral

Screenshot

Features:

  • Arabic ↔ Roman (1 to 3999)
  • Step-by-step breakdown
  • Strict validation (rejects IIII, VV, IIX, etc.)
  • Year converter shortcut
  • 1-100 reference table
  • Historical context panel
  • Japanese / English UI
  • Zero dependencies, 88 tests

The greedy algorithm

const TABLE = [
  [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'],
];

export function toRoman(num) {
  if (!Number.isInteger(num) || num < 1 || num > 3999) {
    throw new Error('out of range (1-3999)');
  }
  let result = '';
  for (const [value, symbol] of TABLE) {
    while (num >= value) {
      result += symbol;
      num -= value;
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

The subtractive pairs (CM, CD, XC, XL, IX, IV) are included in the table alongside the additive symbols. Because the table is in descending order and the greedy picks the largest fit first, subtractive forms are used automatically where needed. 1990 → 1000 (M) + 900 (CM) + 90 (XC) = MCMXC — no special case needed.

Why 3999 is the limit

Standard Roman numerals max out at MMMCMXCIX = 3999. For 4000 and above, you'd need to either use MMMM (which some sources accept and others reject) or a vinculum — a bar over a letter indicating multiplication by 1000. VĖ„ means 5000. This is rarely seen and mostly a historical curiosity, so the validator rejects input > 3999.

Strict validation regex

const ROMAN_REGEX = /^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$/;

export function isValidRoman(str) {
  return ROMAN_REGEX.test(str);
}
Enter fullscreen mode Exit fullscreen mode

This regex enforces the rules:

  • M{0,3} — up to 3 thousands
  • (CM|CD|D?C{0,3}) — hundreds: 900, 400, or 0-300/500-800
  • (XC|XL|L?X{0,3}) — tens: 90, 40, or 0-30/50-80
  • (IX|IV|V?I{0,3}) — ones: 9, 4, or 0-3/5-8

It rejects IIII (4 I's), VV (no V repetition), IIX (no double subtractive prefix), LL, DD. These are all "valid-ish" in some ancient inscriptions but invalid in standard modern notation.

Step-by-step breakdown

The "how did we get there" display uses the same greedy table:

export function convertSteps(num) {
  const steps = [];
  let remaining = num;
  for (const [value, symbol] of TABLE) {
    while (remaining >= value) {
      steps.push({ value, symbol, remaining, after: remaining - value });
      remaining -= value;
    }
  }
  return steps;
}
Enter fullscreen mode Exit fullscreen mode

For 1994 the steps are:

  • 1000 → M (994 remaining)
  • 900 → CM (94 remaining)
  • 90 → XC (4 remaining)
  • 4 → IV (0 remaining)

Result: MCMXCIV. The breakdown helps users understand why 1994 isn't MDCCCCLXXXXIV (the naive additive form).

Roman-to-Arabic

Parsing uses the observation that subtractive values are smaller numerals preceding larger ones:

export function fromRoman(str) {
  if (!isValidRoman(str.toUpperCase())) throw new Error('invalid');
  const values = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
  const s = str.toUpperCase();
  let result = 0;
  for (let i = 0; i < s.length; i++) {
    const cur = values[s[i]];
    const next = values[s[i + 1]];
    if (next && next > cur) {
      result += next - cur;
      i++;
    } else {
      result += cur;
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Scan left to right. If the current symbol is smaller than the next, it's a subtractive pair — add (next - cur) and skip. Otherwise add cur. Works for all valid Roman numerals.

Series

This is entry #88 in my 100+ public portfolio series.

Top comments (0)