Encode/decode number pairs to letters My application state is represented by a string of numbers and I'm trying to encode the string so that it is as small as possible (ie. fewest number of characters). An example string would be: 133223333302302040

I realized that most of these strings consist of number pairs containing 0, 1, 2, 3 (4 and 5 also exist but are rare). I decided to replace the most common number pairs with letters, shortening my encoded string by ~50%

I have a function that returns the mapping:

function getValueMap(lettersFirst = false) {
  const map = {
    '00': 'a',
    '01': 'b',
    '02': 'c',
    '03': 'd',
    '10': 'e',
    '11': 'f',
    '12': 'g',
    '13': 'h',
    '20': 'i',
    '21': 'j',
    '22': 'k',
    '23': 'l',
    '30': 'm',
    '31': 'n',
    '32': 'o',
    '33': 'p'
  };

  if (lettersFirst) {
    return Object.fromEntries(Object.entries(map).map(([k, v]) =&gt; [v, k]));
  } else {
    return map;
  }
}


To encode, I iterate through every pair of input numbers, check if it is in the map, and if so, add the letter to the encoded output. For odd length inputs I simply remove the last digit, dont encode it, and simply append the number to the output string untouched:

encode(text) {
  const map = getValueMap();
  let values;
  let suffix = '';

  if (text.length % 2 !== 0) {
    suffix = text.charAt(text.length - 1);
    values = text.slice(0, -1);
  } else {
    values = text;
  }

  let encoded = '';
  for (let i = 0; i &lt;= text.length - 2; i += 2) {
    if (map[values[i] + values[i + 1]]) {
      encoded += map[values[i] + values[i + 1]];
    } else {
      encoded += values[i] + values[i + 1];
    }
  }
  encoded += suffix;
  return encoded;
}


To decode, iterate through the string, if its a letter, add the corresponding number pair to the decoded output. If its already a number, simply add the number instead:

decode(encoded) {
  const map = getValueMap(true);
  let decoded = '';

  for (let i = 0; i &lt; encoded.length; i++) {
    if (encoded[i].toLowerCase() !== encoded[i].toUpperCase()) {
      decoded += map[encoded[i]];
    } else {
      decoded += encoded[i];
    }
  }

  return decoded;
}


Some example inputs and outputs:

Encode:
13223233202200252044 -&gt; hkopika25i44

Decode:
holppcmi40 -&gt; 133223333302302040


Is there a more straightforward way to do this? My implementation feels unnecessarily obtuse. For example, do I actually need to create a map/reverse map when I could maybe rely on indexing the pair array or alphabet instead? Is there a more elegant way to iterate over pairs of numbers? Or to build up the final encoded/decoded form without appending to a string?
First a few suggestions to your design.
You are effectively trying to represent list of small numbers as String, with no actual meaning (at least that's how I understood it).
I'd consider:


Represent it for what it is - list of small numbers could be represented as byte array. That seems a lot more natural and convenient.
Use some simpler mapping, if it doesn't matter what kind of character is the result. If numbers really are in range between 0 and 32 and not much higher, I'd go for mapping function, that for example adds 65 (and substracts 65 in reverse). That mapping function is really simple and you don't need any kind of map. Your code would be reduced only to initial tokenization basically. 00 becomes A, 30 becomes _ according to their ASCII codes (+65).


In case you wanna keep your String representation, here are my points to your code:

getValueMap


Names getValueMap name and lettersFirst don't really say what much about what do they do. 
There's no point to recalculate reverse map everytime. Flag parameters with this kind of if are typical code smell and bad practice. 
I suggest creating 2 module variables codesToCharacters (original map) and charactersToCodes (once pre-calculated) instead. 


decode


I'd love if this function was just mapping encoded data to decoded data based on mapping function. 
Not sure, why is there that if. I think you are trying to check, if current character is letter or not and if so, map it, otherwise use the character. That took me a while to understand. If there was really need, I would extract that to function with propper name (like isLetter) to increase readability.
Anyway, why even doing that? Why not instead look into the map and if map contains key, use map value, otherwise use original character.


Function would look something like (pseudocode, not tested):

function decode(encoded) {

    return encoded.split("").map(
        c =&gt; {
            charactersToCodes.hasKey(c) ? charactersToCodes[c] : c
        }).join()

}


encode would be on same principle, only difference is, that you need to tokenize it correctly first and then map :)