A non-recursive no-loop solution with nested curried functions for fun:
const convert =
  (base, sym, next) =>
    (num, res = '') =>
      next && num
        ? next(num % base, res + sym.repeat(num / base))
        : res + sym.repeat(num);
const roman = convert(1000,  'M',
              convert( 900, 'CM',
              convert( 500,  'D',
              convert( 400, 'CD',
              convert( 100,  'C',
              convert(  90, 'XC',
              convert(  50,  'L',
              convert(  40, 'XL',
              convert(  10,  'X',
              convert(   9, 'IX',
              convert(   5,  'V',
              convert(   4, 'IV',
              convert(   1,  'I')))))))))))));
roman(1999); //> 'MCMXCIX'
How does it work?
We define a convert function that takes a base number (base), its Roman numeral (sym) and an optional next function that we use for the next conversion. It then returns a function that takes a number (num) to convert and an optional string (res) used to accumulate previous conversions.
Example:
const roman =
  convert(1000, 'M', (num, res) => console.log(`num=${num}, res=${res}`));
//        ^^^^  ^^^  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//        base  sym  next
roman(3999);
// LOG: num=999, res=MMM
Note that roman is the function returned by convert: it takes a number (num) and an optional string res. It is the same signature as the next function…
This means we can use the function returned by convert as a next function!
const roman =
  convert(1000, 'M',
    convert(900, 'CM', (num, res) => console.log(`num=${num}, res=${res}`)));
roman(3999);
// LOG: num=99, res=MMMCM
So we can keep nesting convert functions to cover the entire Roman numerals conversion table:
const roman =
  convert(1000,  'M',
    convert( 900, 'CM',
      convert( 500,  'D',
        convert( 400, 'CD',
          convert( 100,  'C',
            convert(  90, 'XC',
              convert(  50,  'L',
                convert(  40, 'XL',
                  convert(  10,  'X',
                    convert(   9, 'IX',
                      convert(   5,  'V',
                        convert(   4, 'IV',
                          convert(   1,  'I')))))))))))));
When next is not defined it means that we reached the end of the conversion table: convert(1,  'I') which is as simple as repeating 'I' n times e.g. 3 -> 'I'.repeat(3) -> 'III'.
The num check is an early exit condition for when there is nothing left to convert e.g. 3000 -> MMM.