DEV Community

Cover image for Ancient computer science: Let's build a Roman numeral converter from scratch πŸΊπŸ“œ
Pascal Thormeier
Pascal Thormeier

Posted on • Updated on

Ancient computer science: Let's build a Roman numeral converter from scratch πŸΊπŸ“œ

Today, we're going time travelling! Let's go back to the year CCXVII, so 217, all the way to the iron age: The Roman empire.

But today we're not exploring the Colosseum or the Pantheon, neither will we talk to legionnaires or walk the Cursus publicus. Instead we'll learn about a concept that enabled a big part of the Roman economy as well as some of the most magnificent architectural masterpieces. Today's topic are Roman numerals.

Wait, how on earth does CCXVII translate to 217?

A very good question! Let's analyse.

(Short interlude: In case you didn't know, the digits we're used to (0-9), are called "Arabic numerals", since they originated in the western part of the Arabian and North African part of the world. And did you know that the sentence I scratched there is not even true? As @youngdad33 pointed out in the comments, the well-known base 10 digits originated from India, made their way to the Arabic part of the world, were then discovered by Europeans during the Crusades and are hence wrongly dubbed "Arabic numerals". TIL. πŸ˜€)

First of all, what do C, X, V and I mean?

This table gives an overview of the Roman numeral digits and their value:

Roman numeral digit Value
I 1
V 5
X 10
L 50
C 100
D 500
M 1000

Just like base10 numerals, Roman numerals consist of digits. The digits, however, don't exactly correspond to different values at different places (for example, 217 would be 2 * 100, 1 * 10 and 7 * 1), but instead, they add to a larger number. The amount of same digits correspond to the value. We could therefore rewrite CCXVII to C + C + X + V + I + I. With the above table, this translates to 100 + 100 + 10 + 5 + 1 + 1 = 217.

So, for example, the number 4 could be written as IIII, right? Almost! While this might be the intuitive answer, the inventors decided that this was not the way to go. Instead, everything that cannot be written with an addition of maximum three same digits is written as a subtraction from the next larger number. So, instead of writing 1 + 1 + 1 + 1 = 4, we write 5 - 1 = 4, in Roman numerals V - I or simply IV.

In summary, this means if digit A (left of digit B) is smaller than digit B, it's subtracted, otherwise added. To illustrate:

IV --> I < V --> V - I
But:
VI --> V > I --> V + I
Enter fullscreen mode Exit fullscreen mode

This works for any number:

CDXLIV 
--> (D - C) + (L - X) + (V - I) 
= (500 - 100) + (50 - 10) + (5 - 1) = 444

XC = (100 - 10) = 90
Enter fullscreen mode Exit fullscreen mode

However, 99 is not written as 100 - 1, but as (100 - 10) + (10 - 1).

In summary, these are the rules to convert a single digit base 10 number N to Roman numerals:

  • If N <= 3, repeat I 1 to 3 times
  • If N === 4, it's 5 - 1, so VI
  • If N === 5, it's V
  • If N < 9, it's 5 + repeat I 1 to 3 times
  • If N === 9, it's 10 - 1, so IX

If we look at the above table, we'll notice that for every power of 10 up to 1000 (1, 10, 100, 1000), there's singles (1, 10, etc.) and fives (5, 50, 500) - we can therefore repeat the steps above for every power of 10 and change the set of digits we use accordingly.

Coding from base10 to Roman

First, we translate the usual base10 numbers into Roman numerals.

We need a simple map of Roman numerals to numbers:

const romanNumerals = {
  1: 'I',
  5: 'V',
  10: 'X',
  50: 'L',
  100: 'C',
  500: 'D',
  1000: 'M'
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to implement the rules for converting single digits. The rules above can be translated to a set of if statements directly, we only need to know the power of 10 as well, so we chose the right Roman numeral digits:

const romanNumerals = {
  1: 'I',
  5: 'V',
  10: 'X',
  50: 'L',
  100: 'C',
  500: 'D',
  1000: 'M'
}

/**
 * Translates a single digit in respect of the power of 10 into a Roman numeral.
 * @param n
 * @param powerOf10
 * @returns {*|string}
 */
const numDigitToRomDigits = (n, powerOf10) => {
  if (n <= 3) { // I, II, III, X, X, XXX, C, CC, CCC
    return romanNumerals[powerOf10].repeat(n)
  }

  if (n === 4) { // IV, XL, CD
    return romanNumerals[powerOf10] 
      + romanNumerals[powerOf10 * 5]
  }

  if (n === 5) { // V, L, D
    return romanNumerals[powerOf10 * 5]
  }

  if (n < 9) { // VI, VII, VIII, etc.
    return romanNumerals[powerOf10 * 5] 
      + romanNumerals[powerOf10].repeat(n - 5)
  }

  // MC, XC, IX
  return romanNumerals[powerOf10] 
    + romanNumerals[powerOf10 * 10]
}
Enter fullscreen mode Exit fullscreen mode

Let's try this out:

numDigitToRomDigits(7, 10) // "70", yields `LXX`
numDigitToRomDigits(5, 100) // "500", yields `D`
numDigitToRomDigits(3, 1) // "3", yields `III`
numDigitToRomDigits(4, 10) // "40", yields `XL`
Enter fullscreen mode Exit fullscreen mode

That looks good! Now, we can use this function to convert larger numbers:

/**
 * Translates an entire number to Roman numerals.
 * @param x
 * @returns {string}
 */
const num2rom = x => {
  // Split number into digits and reverse, 
  // so figuring out the power of 10 is easier.
  const digits = x.toString()
    .split('')
    .map(n => parseInt(n))
    .reverse()

  // Larger numbers don't work, 5000 is written 
  // as V with a dash on top, we don't have that 
  // character...
  if (x > 3999) {
    throw new Error(
      'Numbers larger than 3999 cannot be converted'
    )
  }

  // Loop over all digits, convert them each
  let romanNum = ''
  for (let i = 0; i < digits.length; i++) {
    romanNum = 
      numDigitToRomDigits(digits[i], 10 ** i) 
      + romanNum // Attach to front of already converted
  }

  return romanNum
}
Enter fullscreen mode Exit fullscreen mode

Let's try that:

num2rom(3724) // yields `MMMDCCXXIV` - works!
Enter fullscreen mode Exit fullscreen mode

From Roman numerals to base 10 again

The other way is going to be bit trickier - we need to parse Roman numerals and convert them back to base10 again. First, we flip the map from earlier. Stackoverflow tells us how to do this.

const flipObject = obj => Object.entries(obj)
  .reduce((acc, [key, value]) => (acc[value] = key, acc), {})

const base10Numerals = flipObject(romanNumerals)

/* yields
{
  C: "100"
  D: "500"
  I: "1"
  L: "50"
  M: "1000"
  V: "5"
  X: "10"
}
*/
Enter fullscreen mode Exit fullscreen mode

The subtraction/addition method is what we're going to implement now. We know that larger numbers left of other numbers are added. If the number on the left is smaller, it's subtracted. So, basically: VI = V + I, but IV = V - I. Since there's no such thing as IIV, we can check the number that comes next to determine if we add or subtract the current number. So, something like this:

From left to right,
If next number to the right is larger:
  Subtract current digit
Else
  Add current digit
Enter fullscreen mode Exit fullscreen mode

In code, it would look like this:

/**
 * Converts a roman number to base10.
 * @param x
 * @returns {number}
 */
const rom2num = x => {
  // Split number and assign base10 
  // value to each digit.
  // parseInt is necessary, because the 
  // flip yields strings.
  const digits = x.split('')
    .map(d => parseInt(base10Numerals[d]))

  let sum = 0
  // Loop over every digit
  for (let i = 0; i < digits.length; i++) {
    // If number to the right larger than the 
    // current number
    if (digits[i + 1] > digits[i]) {
      sum -= digits[i]
    } else {
      sum += digits[i]
    }
  }

  return sum
}
Enter fullscreen mode Exit fullscreen mode

Let's see if that works by converting all numbers from 1 to 3999 back and forth:

let result = true
for (let i = 0; i < 3999; i++) {
  result = result && rom2num(num2rom(i)) === i
}

console.log(result) // true, works!
Enter fullscreen mode Exit fullscreen mode

The result

Now we need some input fields and buttons, and voila:

Phew, enough ancient times for now, let's go back to the 21st century.


I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❀️ or a πŸ¦„! I write tech articles in my free time and like to drink coffee every once in a while.

If you want to support my efforts, you can offer me a coffee β˜• or follow me on Twitter 🐦! You can also support me directly via Paypal!

Buy me a coffee button

Discussion (14)

Collapse
inhuofficial profile image
InHuOfficial

Well I can't believe it, but I spent ages teaching people how to write roman numerals and they still forget 1, 1000, 51, 6 and 500.

IM LIVID! πŸ˜‰πŸ€£

Seriously though, fun little project, I enjoyed the writeup on it too! ❀

Collapse
thormeier profile image
Pascal Thormeier Author

Awesome, didn't know that one! So glad you liked it! :) I'm reading some Terry Pratchet currently and stumbled upon differen roman numbers in character's names and thought "why not explain how they work and make a tutorial about that"

Collapse
lukeshiru profile image
LUKESHIRU • Edited on

Nice! From my personal point of view, you don't need for at all, and there are some things here and there that I would do differently, I just put them all here:

const romanMap = {
    1: "I",
    5: "V",
    10: "X",
    50: "L",
    100: "C",
    500: "D",
    1_000: "M"
};

// Instead of using reduce, we use `Object.fromEntries` in combination with `map`.
const base10Numerals = Object.fromEntries(
    Object.entries(romanMap).map(([key, value]) => [value, key])
);

/**
 * Translates a single digit in respect of the power of 10 into a Roman numeral.
 * @param {number} number
 * @param {number} powerOf10
 * @returns {string}
 */
const digitToRoman = (number, powerOf10) =>
    // We make sure every branch is covered with a ternary instead of if.
    number <= 3
        ? romanMap[powerOf10].repeat(number)
        : number === 4
        ? `${romanMap[powerOf10]}${romanMap[powerOf10 * 5]}`
        : number === 5
        ? romanMap[powerOf10 * 5]
        : number < 9
        ? `${romanMap[powerOf10 * 5]}${romanMap[powerOf10].repeat(number - 5)}`
        : `${romanMap[powerOf10]}${romanMap[powerOf10 * 10]}`;

/**
 * Translates an entire number to Roman numerals.
 * @param {number} number
 * @returns {string}
 */
const numberToRoman = number =>
    // We check that is a number as in not bigger than 3_999.
    typeof number !== "number" || number > 3_999
        ? "" // We return an empty string if is invalid instead of throwing.
        : [...`${number}`]
                // We dont use for, we just map.
                .map((digit, index, numbers) =>
                    // We don't reverse, we just do the calculation here.
                    digitToRoman(
                        parseInt(digit, 10),
                        10 ** (numbers.length - 1 - index)
                    )
                )
                .join("");

/**
 * Converts a roman number to base10.
 * @param {string} roman
 * @returns {number}
 */
const romanToNumber = roman =>
    // Again no need for for, just some mapping and reducing.
    typeof roman === "string"
        ? [...roman]
                .map(numeral => parseInt(base10Numerals[numeral], 10))
                .reduce(
                    (total, digit, index, digits) =>
                        digits[index + 1] > digit
                            ? total - digit
                            : total + digit,
                    0
                )
        : NaN;
Enter fullscreen mode Exit fullscreen mode

Cheers!

Collapse
thormeier profile image
Pascal Thormeier Author

Thank you for your inputs, very valuable and they do improve the performance! Glad you liked the post. :) It's true that I could've used the array methods like reduce and the spread operator more often. I omitted them on purpose though, as they add complexity for the reader, especially if they're not familiar with them, so they can concentrate on the functionality of the algorithm.

Collapse
youngdad33 profile image
Dominic Ross

Not to be "that guy", but western numbers are not Arabian. They actually came from India. They were called Arabic numbers because at the time they made their way to the western world (modern day Europe), the crusades were happening and that's where we got the knowledge of "Arabic" numerals. They made their way to Arabia from trade links between them and India via the silk road.

I'm no expert, but I got this from a podcast called "you're dead to me" on BBC about history. Very interesting (no, I'm not affiliated 🀣)

Collapse
thormeier profile image
Pascal Thormeier Author

After doing a bit of research myself, I conclude that I should've done my research better before hitting that "Publish" button... πŸ˜… You're absolutely right, will correct that right away, thank you!

Collapse
thormeier profile image
Pascal Thormeier Author

Scratched the original explanation and added the correct one with credits to you. I really didn't do my research good enough there and ended up with this false piece of info. Thank you for pointing that out, helps a great deal to improve the quality of my posts πŸ™‚

Thread Thread
youngdad33 profile image
Dominic Ross

Didn't mean to be a detractor from a very good written article. I only learnt a couple of weeks ago myself from that podcast and had always thought they were Arabic too. Just spreading the useless information I pickup so easily (just wish I could do the same with coding πŸ˜‚).

Collapse
jayjeckel profile image
Jay Jeckel

Very fun puzzle and a great article as well!

Interesting additional information, the rules that we consider normal for Roman numerals are actually a fairly modern invention. The simplest example is that IIII was often used instead of IV, and still is in some cases such as certain clocks. There were also cases of multiple leading subtractive symbols being used, such as IIXX for 18 instead of XVIII, due to how they spoke, in this case "two from twenty".

Such an interesting problem, seems simple on the surface, but the deeper you dig the more edge cases their are.

Collapse
thormeier profile image
Pascal Thormeier Author

Thank you very much, glad you enjoyed it! I didn't know that, thank you! The code in the article is not really capable of handling, for example, the IIXX case, even though it probably wouldn't take much to cover that, too. It's something I really like about our number system today: There's only ever one way to write a number (that is, if you don't count mathematical expressions or complex numbers...)

(Sorry for the late reply)

Collapse
alexmitchelldev profile image
Alex Mitchell • Edited on

Thank you for this solution Pascal!

I am a new programmer/developer and really enjoyed working through your solution, I have added it to my list to go back through the code to get a deeper understanding.

I really liked how you reversed digits then used i from the for loop to calculate powerOf10, nice!

Cheers,

Alex :)

Collapse
thormeier profile image
Pascal Thormeier Author

You're very welcome, so glad my post helped you. πŸ˜€ If you have any questions about it, please feel free to ask, I'm always happy to help!

Collapse
lisaironcutter profile image
Lisa Tagliaferri

Always great to see Ancient Rome and Computer Science together ❀

Collapse
thormeier profile image
Pascal Thormeier Author

I'm sure there are many more concepts in ancient mathematics, engineering, astronomy or even economics one could rebuild with JS or any other language! Is there any topic you yourself would be particularly interested in?

(Sorry for the late reply)