My learning diary

TypeScript Shortcuts

TL;DR: I learnt how to (invert a map) and (copy a map and override the values of the copy) in TypeScript. Ctrl+F TYPESCRIPT_SHORTCUT_1 and TYPESCRIPT_SHORTCUT_2 to get to the code.

Context: Yesterday, I didn’t invest time into inverting a TypeScript Map<string, string>. Today, I received feedback that I should extract the encode logic into a function of its own. I decided to put in more effort.

Here’s the new original code:

import { AES } from 'crypto-js';
import Utf8 from 'crypto-js/enc-utf8';

const passphrase = 'blah';
const substitutionsAfterEncryption: Map<string, string> = new Map([
    ['+', '-'],
    ['/', '_'],
    ['=', '~'],
]);
const substitutionsBeforeDecryption: Map<string, string> = new Map([
    ['-', '+'],
    ['_', '/'],
    ['~', '='],
]);

export const encrypt = (plaintext: string) => {
    const encrypted = AES.encrypt(plaintext, passphrase).toString();
    return encrypted.replace(
        /[+/=]/g,
        (match: string) => substitutionsAfterEncryption.get(match) ?? match
    );
};

export const decrypt = (cipher: string) => {
    const toDecrypt = cipher.replace(
        /[-_~]/g,
        (match: string) => substitutionsBeforeDecryption.get(match) ?? match
    );
    return AES.decrypt(toDecrypt, passphrase).toString(Utf8);
};

Here’s the updated code:

import { AES } from 'crypto-js';
import Utf8 from 'crypto-js/enc-utf8';

const passphrase = 'blah';
const encodeMap = new Map([
    ['+', '-'],
    ['/', '_'],
    ['=', '~'],
]);
// Inverting a map in TypeScript is more problematic than in JavaScript.
// The extra .map call is to tell TypeScript that it is dealing with an
// iterable of 2-tuples instead of string[][].
// See https://github.com/microsoft/TypeScript/issues/8936.
const decodeMap = new Map(
    Array.from(encodeMap, (tuple) => tuple.reverse()).map<[string, string]>((array) => [
        array[0],
        array[1],
    ])
);

const replaceSymbols = (cipher: string, substitutionMap: Map<string, string>) => {
    const symbols = Array.from(substitutionMap.keys()).join('');
    return cipher.replace(
        new RegExp(`[${symbols}]`, 'g'),
        (match: string) => substitutionMap.get(match) ?? match
    );
};

export const encrypt = (plaintext: string) => {
    return replaceSymbols(AES.encrypt(plaintext, passphrase).toString(), encodeMap);
};

export const decrypt = (cipher: string) => {
    return AES.decrypt(replaceSymbols(cipher, decodeMap), passphrase).toString(Utf8);
};

While I failed to shorten the code, I reduced the number of literals. I’m left with encodeMap and the regex now. From this experience, I figured out why I couldn’t invert a map like how it’s done in JavaScript. It’s a typing ambiguity (refer to the comments above).

So, to invert a map in TypeScript, do:

// TYPESCRIPT_SHORTCUT_1: Inverting a map
// Remember to replace THE_MAP_YOU_WANT_TO_INVERT, KEY_TYPE and VALUE_TYPE.
new Map(
    Array.from(THE_MAP_YOU_WANT_TO_INVERT, (tuple) => tuple.reverse()).map<[VALUE_TYPE, KEY_TYPE]>((array) => [
        array[0],
        array[1],
    ])
)

Later in the day, I was firefighting a bug. I realised that I was the one who introduced the regression that went unnoticed for a while. At least I got to clean up my own mess… The bug occurred because I was iterating through a TypeScript map keys the JavaScript (wrong) way.

The wrong way:

// Remember to replace THE_MAP_YOU_WANT_TO_COPY and 'BLAH'.
// You shouldn't be copying this anyway.
Object.keys(
    THE_MAP_YOU_WANT_TO_COPY
).reduce((accumulator, key) => {
    accumulator.set(key, 'BLAH'));
    return accumulator;
}, new Map<string, string>());

Correct way:

// TYPESCRIPT_SHORTCUT_2: Copying a map and overriding the values of the copy
// Remember to replace THE_MAP_YOU_WANT_TO_COPY and 'BLAH'.
Array.from(
    THE_MAP_YOU_WANT_TO_COPY.keys()
).reduce((accumulator, key) => {
    accumulator.set(key, 'BLAH'));
    return accumulator;
}, new Map<string, string>());

So much for copying. I hope I can more careful with these in the future.

Relevant posts