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
IIIIis "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
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;
}
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);
}
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;
}
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;
}
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.
- ðĶ Repo: https://github.com/sen-ltd/roman-numeral
- ð Live: https://sen.ltd/portfolio/roman-numeral/
- ðĒ Company: https://sen.ltd/

Top comments (0)