Roman Numeral Converter — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure Roman numeral converter. Zero deps - the unit-test surface for the
// Roman Numeral Converter tool.
//
// Range 1..3,999,999. Values above 3,999 use VINCULUM notation: an overline
// above a symbol multiplies its value by 1,000 (V̅ = 5,000, M̅ = 1,000,000).
// The canonical string carries a combining overline (U+0305) on each scaled
// symbol, so it copies and parses as plain text.
//
// One ordered (value, symbol) table drives everything. The canonical form is a
// two-part composition: overline(render(thousands)) + render(remainder). The
// overlined part is the same greedy algorithm applied to the thousands digits,
// which is why the same 13-entry table covers the whole range.
/** Combining overline - the vinculum mark (renders as a bar above a glyph). */
export const OVERLINE = '\u0305';
/** Combining macron - visually near-identical; accepted on input, normalized. */
const MACRON = '\u0304';
/** Largest representable value: 3,999 thousands + 999 remainder. */
export const MAX_ROMAN = 3_999_999;
/** The base (value, symbol) pairs for 1..3,999, largest first. */
const BASE: readonly (readonly [number, string])[] = [
[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'],
];
/** Each letter's face value - the whole numeral system in seven entries. */
export const LETTER_VALUES: Readonly<Record<string, number>> = {
I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000,
};
/** Attach a combining overline to every character (vinculum: value x 1,000). */
function overline(s: string): string {
return [...s].map((ch) => ch + OVERLINE).join('');
}
function inRange(n: number): boolean {
return Number.isInteger(n) && n >= 1 && n <= MAX_ROMAN;
}
/** Greedy pass over BASE for a value in 1..3,999. */
function toRomanBase(v: number): string {
let out = '';
for (const [val, sym] of BASE) {
while (v >= val) {
out += sym;
v -= val;
}
}
return out;
}
/** Value of a known-valid [MDCLXVI]+ string: one left-to-right scan where a
* smaller letter before a larger one subtracts (IV = 4, CM = 900). Returns
* junk values for non-canonical strings - the round-trip in fromRoman is the
* canonicality gate. */
function scanValue(s: string): number {
let total = 0;
for (let i = 0; i < s.length; i++) {
const v = LETTER_VALUES[s[i]];
total += i + 1 < s.length && LETTER_VALUES[s[i + 1]] > v ? -v : v;
}
return total;
}
/** Convert an integer (1..3,999,999) to a Roman numeral, or null if out of range. */
export function toRoman(n: number): string | null {
if (!inRange(n)) return null;
if (n <= 3999) return toRomanBase(n);
const thousands = Math.floor(n / 1000);
const rest = n % 1000;
return overline(toRomanBase(thousands)) + (rest ? toRomanBase(rest) : '');
}
export interface RomanParts {
/** The x1,000 glyphs, WITHOUT combining marks - for CSS-overline display. */
overlined: string;
/** The plain remainder glyphs (0..999). */
plain: string;
}
/** Split the numeral into display segments: overlined thousands + plain rest. */
export function toRomanParts(n: number): RomanParts | null {
if (!inRange(n)) return null;
if (n <= 3999) return { overlined: '', plain: toRomanBase(n) };
return {
overlined: toRomanBase(Math.floor(n / 1000)),
plain: toRomanBase(n % 1000),
};
}
export interface RomanStep {
/** The matched symbol, combining overlines included (I̅V̅ = 4,000). */
symbol: string;
/** The symbol's value (4,000 for I̅V̅). */
value: number;
}
/** Greedy (symbol, value) steps for one part; scaled marks the x1,000 half. */
function stepsFrom(v: number, scaled: boolean): RomanStep[] {
const steps: RomanStep[] = [];
for (const [val, sym] of BASE) {
while (v >= val) {
steps.push({
symbol: scaled ? overline(sym) : sym,
value: scaled ? val * 1000 : val,
});
v -= val;
}
}
return steps;
}
/** Greedy breakdown: the ordered (symbol, value) steps summing to n. */
export function toRomanSteps(n: number): RomanStep[] | null {
if (!inRange(n)) return null;
if (n <= 3999) return stepsFrom(n, false);
const steps = stepsFrom(Math.floor(n / 1000), true);
const rest = n % 1000;
if (rest) steps.push(...stepsFrom(rest, false));
return steps;
}
/** Parse a Roman numeral to an integer, or null if not canonical form. */
export function fromRoman(s: string): number | null {
// Trim, uppercase, and treat a pasted macron as the canonical overline mark.
const input = s.trim().toUpperCase().split(MACRON).join(OVERLINE);
if (!/^(?:[MDCLXVI]\u0305?)+$/.test(input)) return null;
// Split into the overlined glyphs and the plain glyphs. Canonical form keeps
// every overlined glyph before every plain one - anything else fails the
// round-trip below.
let overStr = '';
let plainStr = '';
for (let i = 0; i < input.length; i++) {
if (input[i + 1] === OVERLINE) {
overStr += input[i];
i++;
} else {
plainStr += input[i];
}
}
const over = overStr ? scanValue(overStr) : 0;
const plain = plainStr ? scanValue(plainStr) : 0;
const total = over * 1000 + plain;
// Reject non-canonical forms (e.g. "IIII", "VV", "IC", plain "MMMM" for
// 4,000, mixed-scale "M̅M") via round-trip: only the canonical rendering
// of the total equals the input.
return total >= 1 && total <= MAX_ROMAN && toRoman(total) === input ? total : null;
}
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 →