Skip to content

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 →