Skip to main content

Roman Numerals

The challenge​

Given a positive integer, return its Roman numeral representation as a string.

Modern Roman numerals are written by expressing each decimal digit separately, starting with the leftmost, and skipping any digit with a value of zero. You cannot write numerals like IM for 999 — subtraction is only ever a single smaller numeral immediately before a larger one (IV, IX, XL, XC, CD, CM).

Examples:

  • 1990 → "MCMXC" (1000 → "M" + 900 → "CM" + 90 → "XC")
  • 2008 → "MMVIII" (2000 → "MM" + 8 → "VIII")
  • 99 → "XCIX" (90 → "XC" + 9 → "IX")
  • 47 → "XLVII" (40 → "XL" + 7 → "VII")

Remember to follow the red-green-refactor cycle and, if you're using an AI coding assistant, keep it on a tight leash — one test at a time.

Suggested test list​

Build this one up gradually — don't jump straight to a general algorithm. A common approach is to earn each symbol one at a time:

  • 1 → "I".
  • 2 or 3 → repeated symbols ("II", "III").
  • 4 → "IV" — this is where simple repetition breaks down and forces a design decision.
  • 5 → "V".
  • 9 → "IX".
  • 10, 40, 50, 90 → introduce X, XL, L, XC.
  • 100, 400, 500, 900 → introduce C, CD, D, CM.
  • 1000 → "M".
  • A number that exercises several groups at once, e.g. 1994 → "MCMXCIV".
  • The worked examples above (1990, 2008, 99, 47) as regression tests once your general solution exists.
  • What should happen for 0, or a negative number? The challenge says the input is a positive integer — decide (and test) what your function does if that assumption is violated.
  • What's the largest number your solution can represent? Standard Roman numerals top out at 3999 (MMMCMXCIX) before you'd need repeating Ms beyond what's conventionally written — decide and test what your function does at and beyond that boundary.

Your task​

Implement the solution using strict TDD, one test from the list at a time. Resist the urge to write the general subtractive-pairs algorithm on your very first test — let the tests force you towards it.

💡 Show a reference solution (only after you've completed your own via TDD)
package romannumerals

var valueSymbols = []struct {
Value int
Symbol 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"},
}

func ToRoman(number int) string {
result := ""
for _, vs := range valueSymbols {
for number >= vs.Value {
result += vs.Symbol
number -= vs.Value
}
}
return result
}
// example tests, e.g. with Go's testing package
func TestToRoman(t *testing.T) {
cases := []struct {
Input int
Expected string
}{
{1, "I"},
{4, "IV"},
{9, "IX"},
{47, "XLVII"},
{99, "XCIX"},
{1990, "MCMXC"},
{2008, "MMVIII"},
}
for _, c := range cases {
got := ToRoman(c.Input)
if got != c.Expected {
t.Errorf("ToRoman(%d): got %q, want %q", c.Input, got, c.Expected)
}
}
}

Notice how the greedy, "largest value that fits" approach naturally produces the subtractive forms (IV, CM, etc.) for free, once 900/CM, 400/CD, 90/XC, 40/XL, 9/IX, and 4/IV are in the lookup table alongside the "plain" values.