Roman Numeral Converter — Arabic ↔ Roman up to 3,999,999
By Codcompass Team··8 min read
Engineering a Production-Ready Roman Numeral Engine in TypeScript
Current Situation Analysis
The Industry Pain Point
Most development teams treat Roman numeral conversion as a trivial algorithmic exercise, often relegating it to coding interview prep or simple utility scripts. This mindset leads to fragile implementations that fail in production environments. Standard libraries rarely support extended ranges beyond 3,999, and custom implementations frequently lack rigorous validation, allowing non-canonical forms (like IIII or VV) to propagate through systems. When applications require historical data processing, legal document generation, or specialized numbering systems, these oversights cause data integrity failures and user-facing errors.
Why This Problem is Overlooked
Developers often assume Roman numerals are a closed set of rules limited to the I-V-X-L-C-D-M symbols. However, professional use cases demand support for the vinculum (overline) notation, which extends the range by multiplying values by 1,000. Additionally, the complexity of validation is underestimated. A converter that simply maps symbols to values without enforcing subtractive notation rules will accept invalid inputs, leading to silent data corruption. The round-trip validation technique is powerful but frequently ignored in favor of brittle regular expressions.
Data-Backed Evidence
The source material demonstrates a robust approach capable of handling values up to 3,999,999 using extended notation (e.g., M̄ for 1,000,000). Implementations that omit the extended symbol table are capped at 3,999, rendering them insufficient for modern requirements. Furthermore, validation via round-trip conversion is the only method that guarantees canonical compliance without maintaining a complex state machine for every possible invalid permutation.
WOW Moment: Key Findings
The critical insight in building a reliable Roman numeral engine is that validation is best achieved through canonicalization, not pattern matching. By decoding input to a numeric value and immediately re-encoding it, you generate the strictly correct representation. Any discrepancy between the re-encoded output and the original input indicates a violation of Roman numeral rules.
Approach
Validation Strictness
Max Range Support
Implementation Complexity
Canonical Compliance
Regex Pattern Matching
Low
3,999
High (fragile patterns)
Fails on edge cases
Naive Symbol Mapping
None
3,999
Low
Accepts IIII, VV
Greedy Map + Round-Trip
High
3,999,999
Medium
Guaranteed
This finding enables systems to reject malformed inputs automatically while supporting the full extended range required by enterprise applications. The round-trip check acts as a self-healing validator that catches every non-canonical form, including obscure violations like VIV or IC.
Core Solution
Technical Implementation Strategy
The engine relies on three pillars: a descending-sorted symbol table, a greedy encoding algorithm, and a bidirectional validation loop.
Symbol Table Design: The mapping must include subtractive pairs (e.g., 900,000 as C̄M̄) and extended notation symbols. The array must be sorted in descending order of value to ensure the greedy algorithm selects the largest possible symbol at each step.
Greedy Encoding: To convert Arabic to Roman, iterate through the symbol table. For each symbol, subtract its value fro
m the remainder while appending the symbol to the result. This works because the symbol set is canonical; the greedy choice property holds.
3. Iterative Decoding: To convert Roman to Arabic, scan the input string. Match the longest possible prefix against the symbol table, add the value, and advance the cursor. This handles multi-character symbols like C̄M̄ correctly.
4. Round-Trip Validation: Decode the input string to a number. Re-encode that number. Compare the result with the original input. If they match, the input is valid and canonical.
TypeScript Implementation
export interface RomanSymbol {
readonly value: number;
readonly glyph: string;
}
/**
* Canonical symbol table sorted descending.
* Includes extended notation with macrons for values >= 1,000.
* M̄ represents 1,000,000. C̄M̄ represents 900,000.
*/
const ROMAN_SYMBOLS: ReadonlyArray<RomanSymbol> = [
{ value: 1_000_000, glyph: 'M̄' },
{ value: 900_000, glyph: 'C̄M̄' },
{ value: 500_000, glyph: 'D̄' },
{ value: 400_000, glyph: 'C̄D̄' },
{ value: 100_000, glyph: 'C̄' },
{ value: 90_000, glyph: 'X̄C̄' },
{ value: 50_000, glyph: 'L̄' },
{ value: 40_000, glyph: 'X̄L̄' },
{ value: 10_000, glyph: 'X̄' },
{ value: 9_000, glyph: 'MX̄' },
{ value: 5_000, glyph: 'V̄' },
{ value: 4_000, glyph: 'MV̄' },
{ value: 1_000, glyph: 'M' },
{ value: 900, glyph: 'CM' },
{ value: 500, glyph: 'D' },
{ value: 400, glyph: 'CD' },
{ value: 100, glyph: 'C' },
{ value: 90, glyph: 'XC' },
{ value: 50, glyph: 'L' },
{ value: 40, glyph: 'XL' },
{ value: 10, glyph: 'X' },
{ value: 9, glyph: 'IX' },
{ value: 5, glyph: 'V' },
{ value: 4, glyph: 'IV' },
{ value: 1, glyph: 'I' },
] as const;
export class RomanNumeralEngine {
/**
* Converts an Arabic integer to a Roman numeral string.
* Uses a greedy algorithm leveraging the descending symbol table.
*/
public static toRoman(value: number): string {
if (!Number.isInteger(value) || value <= 0 || value > 3_999_999) {
throw new RangeError('Value must be an integer between 1 and 3,999,999.');
}
const result: string[] = [];
let remainder = value;
for (const { value: symVal, glyph } of ROMAN_SYMBOLS) {
while (remainder >= symVal) {
result.push(glyph);
remainder -= symVal;
}
}
return result.join('');
}
/**
* Parses a Roman numeral string into an Arabic integer.
* Scans for the longest matching prefix to handle multi-char symbols.
*/
public static fromRoman(input: string): number {
if (typeof input !== 'string' || input.length === 0) {
throw new TypeError('Input must be a non-empty string.');
}
let accumulator = 0;
let cursor = 0;
const length = input.length;
while (cursor < length) {
let matched = false;
// Check symbols in descending order to find the longest match
for (const { value: symVal, glyph } of ROMAN_SYMBOLS) {
if (input.startsWith(glyph, cursor)) {
accumulator += symVal;
cursor += glyph.length;
matched = true;
break;
}
}
if (!matched) {
throw new SyntaxError(`Invalid Roman numeral sequence at position ${cursor}.`);
}
}
return accumulator;
}
/**
* Validates a Roman numeral string using round-trip canonicalization.
* Rejects non-canonical forms like IIII, VV, or mixed case errors.
*/
public static isValid(input: string): boolean {
try {
const numericValue = this.fromRoman(input);
const canonicalOutput = this.toRoman(numericValue);
return canonicalOutput === input;
} catch {
return false;
}
}
}
Architecture Decisions
Descending Sort Order: The ROMAN_SYMBOLS array is strictly ordered from largest to smallest. This is non-negotiable for the greedy algorithm to produce correct results. If IV appeared before I, the encoder would still work, but the decoder might fail to match IV if it checks I first.
Array Join vs String Concatenation: The encoder uses an array accumulator and join('') rather than string concatenation in the loop. This avoids creating intermediate string objects, improving performance in tight loops.
Readonly Interfaces: The symbol table is typed as ReadonlyArray to prevent accidental mutation at runtime, ensuring thread safety in concurrent environments.
Error Handling: The decoder throws descriptive errors for invalid sequences, allowing callers to distinguish between malformed input and out-of-range values.
Pitfall Guide
Ascending Symbol Order
Explanation: Sorting the symbol table ascending breaks the greedy logic. The encoder might produce IIII instead of IV if it processes I before V.
Fix: Always sort the mapping array in descending order of value. Add a unit test that asserts the sort order.
Unicode Normalization Mismatch
Explanation: Characters like M̄ can be represented as a precomposed character or as M followed by a combining macron (\u0304). String comparison in validation may fail if the input uses a different Unicode normalization form than the symbol table.
Fix: Normalize all inputs using String.prototype.normalize('NFC') before processing. Ensure the symbol table uses the same normalization form.
Missing Subtractive Pairs
Explanation: Omitting pairs like 900 (CM) or 900,000 (C̄M̄) forces the encoder to produce non-canonical forms like DCCCC. This breaks round-trip validation.
Fix: Include all standard subtractive pairs in the symbol table. Verify coverage by testing boundary values (e.g., 9, 90, 900, 9000, 90000, 900000).
Ignoring Case Sensitivity
Explanation: Roman numerals are case-sensitive. iv is invalid. The validation logic must enforce uppercase.
Fix: The round-trip check naturally handles this if the symbol table uses uppercase. Ensure the decoder does not silently lowercase input.
Performance Degradation in Decoding
Explanation: A naive decoder that checks every symbol at every position can be O(N*M) where N is string length and M is symbol count.
Fix: The provided implementation breaks early upon matching the longest prefix, keeping complexity linear relative to the input length. For extreme performance needs, consider a trie-based decoder, though the linear scan is sufficient for most use cases.
Off-by-One Range Errors
Explanation: Failing to enforce the upper bound of 3,999,999 allows the encoder to produce invalid output or loop infinitely if the symbol table is incomplete.
Fix: Explicitly check value <= 3_999_999 in the encoder. Document the range limitation clearly.
Assuming Input Validity
Explanation: Calling fromRoman on untrusted input without validation can lead to unexpected numeric results or crashes.
Fix: Always run isValid before processing user input, or wrap fromRoman in a try-catch block. Never trust client-side Roman numeral data.
Production Bundle
Action Checklist
Verify Symbol Order: Ensure the symbol table is sorted descending and includes all subtractive pairs up to 900,000.
Implement Round-Trip Validation: Use the decode-then-encode pattern for all validation logic; avoid regex-based checks.
Handle Unicode Normalization: Apply normalize('NFC') to all inputs to prevent comparison failures with macron characters.
Add Boundary Tests: Create test cases for 1, 3,999, 4,000, 3,999,999, and invalid forms like IIII, VV, IC.
Enforce Range Limits: Validate inputs against the 1–3,999,999 range before processing.
Optimize String Building: Use array accumulation and join in the encoder for better performance.
Document Canonical Rules: Clearly state that only standard subtractive notation is accepted; additive forms are rejected.
Decision Matrix
Scenario
Recommended Approach
Why
Cost Impact
User Input Validation
Round-Trip Check
Guarantees canonical compliance; rejects all non-standard forms automatically.
Low CPU overhead; high reliability.
High-Volume Encoding
Greedy Algorithm with Array Join
O(1) effective complexity due to fixed symbol set; minimal memory allocation.
Negligible cost; scales linearly.
Legacy Data Migration
Lenient Parser + Normalization
Legacy data may contain non-canonical forms; normalize before validation.
Higher dev time; prevents data loss.
Display-Only Use Case
Simple Mapping
If validation is not required, a direct lookup suffices.
try {
RomanNumeralEngine.toRoman(4_000_000);
} catch (error) {
console.error(error.message);
// Output: 'Value must be an integer between 1 and 3,999,999.'
}
🎉 Mid-Year Sale — Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all 635+ tutorials.