28

Hit this odd bug in my code and I can't figure the way to get a constant time lookup from a Map when using a tuple as my key.

Hopefully this illustrates the issue, and the workaround I'm using now just to get it to work:

hello.ts:

let map: Map<[number, number], number> = new Map<[number, number], number>()
    .set([0, 0], 48);

console.log(map.get([0,0])); // prints undefined

console.log(map.get(String([0, 0]))); //  compiler:  error TS2345: Argument of type 
// 'string' is not assignable to parameter of type '[number, number]'.

//the work-around:
map.forEach((value: number, key: [number, number]) => {
    if(String(key) === String([0, 0])){
        console.log(value); // prints 48
    }
})

To compile (transpile?) I'm using:

tsc hello.ts -target es6

tsc version 2.1.6

Tried several things to make the Map.get() method to work, not having much success.

6 Answers 6

42

In JavaScript (and as an extension, TypeScript), no two arrays are equal except if they refer to the same array (i.e., when changing the elements of one also would change the elements of another). If you create a new array with the same elements, it would not consider it to be equal to any existing one.

Because Maps consider such equality when looking up elements, if you store a value with an array as a key, you can only get the value out again if you pass in the exact same array reference as a key again:

const map: Map<[ number, number], number> = new Map<[ number, number ], number>();

const a: [ number, number ] = [ 0, 0 ];
const b: [ number, number ] = [ 0, 0 ];

// a and b have the same value, but refer to different arrays so are not equal
a === b; // = false

map.set(a, 123);
map.get(a); // = 123
map.get(b); // = undefined

One simple workaround for this is to use strings or numbers as keys, as these are always considered equal when they have the same value:

const map: Map<string, number> = new Map<string, number>();

const a: [ number, number ] = [ 0, 0 ];
const b: [ number, number ] = [ 0, 0 ];

const astr: string = a.join(','); // = '0,0'
const bstr: string = b.join(','); // = '0,0'

// astr and bstr have the same value, and are strings so they are always equal
astr === bstr; // = true

map.set(astr, 123);
map.get(astr); // = 123
map.get(bstr); // = 123
Sign up to request clarification or add additional context in comments.

2 Comments

Makes perfect sense. Thanks!
Meta point: there's a website that's literally lifted list comment without apparent attribution as a 'blog post': newbedev.com/typescript-javascript-using-tuple-as-key-of-map -- seems very shady.
11

I would create my own class to do this so that I can easily use all of the map methods:

class MyMap {
    private map = new Map<string, number>();

    set(key: [number, number], value: number): this {
        this.map.set(JSON.stringify(key), value);
        return this;
    }

    get(key: [number, number]): number | undefined {
        return this.map.get(JSON.stringify(key));
    }

    clear() {
        this.map.clear();
    }

    delete(key: [number, number]): boolean {
        return this.map.delete(JSON.stringify(key));
    }

    has(key: [number, number]): boolean {
        return this.map.has(JSON.stringify(key));
    }

    get size() {
        return this.map.size;
    }

    forEach(callbackfn: (value: number, key: [number, number], map: Map<[number, number], number>) => void, thisArg?: any): void {
        this.map.forEach((value, key) => {
            callbackfn.call(thisArg, value, JSON.parse(key), this);
        });
    }
}

(code in playground)

As you can see, the forEach for example will automatically give you the key as [number, number] instead of a string which you will then need to parse.

Usage example:

let map = new MyMap();
map.set([1, 2], 4);
console.log(map.get([1, 2])) // 4

map.set([3, 4], 20);
map.forEach((v, k) => console.log(k, v));
// prints:
// [1, 2] 4
// [3, 4] 20

1 Comment

There is also collectionsjs.com with which you can override contentEquals and contentHash methods to determine key equivalences.
5

In some cases (e.g. when the second value in the tuple depends on the first value) I think it may be possible to use a nested map instead:

// situation: a map from a tuple of (tableId, rowId) to the row's title

// instead of Map<[number, number], string> where the first number is
// tableId and the second number is rowId, we can have:
const rowTitleMap = Map<number, Map<number, string>>

const title = rowTitleMap.get(2)?.get(4) // can be string or undefined

1 Comment

Nested maps is the go-to solution if you need to use objects in the key tuples of the map.
1

I don't know whether this would work with Typescript or if there might be other downsides but this seems like a simple to use method to me that will retain the keys as tuples:

const key_map_key_string = (tuple) => JSON.stringify(tuple);

const string_identical_tuple_key = (map_for_keying = new Map()) => {
  let key_map = new Map();
  [...map_for_keying.keys()].forEach((key) => key_map.set(key_map_key_string(key), key));

  return (tuple) => {
    const res = key_map.get(key_map_key_string(tuple));
    if(res) return res;

    key_map.set(key_map_key_string(tuple), tuple);
    return tuple;
  };
};

const test = () => {
  let a_map = new Map([
    [[1, 2], 'value1'],
    [[3, 4], 'value2']
  ]);
  
  const get_key = string_identical_tuple_key(a_map);
  
  console.log(a_map.get( get_key([1, 2]) ) === 'value1');
  
  a_map.set(get_key([5, 6]), 'value3');
  
  console.log(a_map.get( get_key([5, 6]) ) === 'value3');
  
  a_map.set(get_key([3, 4]), 'value4');
  
  console.log(JSON.stringify([...a_map]));
};

test();

Comments

1

As mentioned in some other answers, you are seeing this behavior because two different array instances containing the same sequence of values are not considered equal because they are compared by reference:

const array1 = [0, 0]
const array2 = [0, 0]
console.log(array1 === array2)
//=> false

Map uses the same notion of equality to associate keys with values:

const array1 = [0, 0]
const array2 = [0, 0]

const map = new Map([[array1, 48]])

console.log(map.get(array1))
//=> 48

console.log(map.get(array2))
//=> undefined

You can use the keyalesce, which returns the same key for the same sequence of values, to solve this problem.

const array1 = [0, 0]
const array2 = [0, 0]

const map = new Map([[keyalesce(array1), 48]])

console.log(map.get(keyalesce(array1)))
//=> 48

console.log(map.get(keyalesce(array2)))
//=> 48

See this post for how keyalesce works internally.

Comments

0

When facing the issue of tuple map keys, if any of the keys of the tuple is non-Scalar, then the issue should be handled using nested Maps, see the following example with a tuple of two values:

// An animal care enterprise has a fleet of vehicles and has decided that for
// each animal and each vehicle, the right for the animal to enter the vehicle
// would be either:
// - to be defined
// - authorized
// - unauthorized
// This situation corresponds to a map from tuples of (Animal, Vehicle) to
// boolean. It can be handled in JS/TS using nested Map instances:

const animalA = new Animal();
const vehicleA = new Vehicle();

// instead of Map<[Animcal, Vehicle], boolean>, we have:
const animalCanEnterVehicle: Map<Animal, Map<Vehicle, boolean>> = new Map();

// Values:
// - true: authorized
// - false: unauthorized
// - undefined: to be defined

// Reading
const authorized = animalCanEnterVehicle.get(animalA)?.get(vehicleA);

// Writing
const vehicleMapForAnimal = animalCanEnterVehicle.get(animalA) ?? new Map();
vehicleMapForAnimal.set(vehicle, authorized);
animalCanEnterVehicle.set(animalA, vehicleMapForAnimal);

// Removing
const vehicleMapForAnimal = animalCanEnterVehicle.get(animalA);
if (vehicleMapForAnimal) {
  vehicleMapForAnimal.remove(vehicleA);
  if (vehicleMapForAnimal.size === 0) {
    animalCanEnterVehicle.delete(animalA);
  }
}

It is possible to create a custom class which manages mapping pairs of values for keys:

export class PairMap<TKA, TKB, TV> {
  root: Map<TKA, Map<TKB, TV>>
  constructor() {
    this.root = new Map()
  }
  get size() {
    let total = 0
    for (const map of this.root.values()) {
      total += map.size
    }
    return total
  }
  has(keyA: TKA, keyB: TKB): boolean {
    return this.root.get(keyA)?.has(keyB) ?? false
  }
  get(keyA: TKA, keyB: TKB): TV {
    return this.root.get(keyA)?.get(keyB)
  }
  set(keyA: TKA, keyB: TKB, value: TV) {
    const map = this.root.get(keyA) ?? new Map()
    map.set(keyB, value)
    this.root.set(keyA, map)
  }
  delete(keyA: TKA, keyB: TKB): boolean {
    const map = this.root.get(keyA)
    let result = false
    if (map) {
      result = map.delete(keyB)
      if (map.size === 0) {
        this.root.delete(keyA)
      }
    }
    return result
  }
  clear() {
    this.root.clear()
  }
  keys() {
    return [...this.root.entries()].flatMap(([keyA, map]) =>
      [...map.keys()].map((keyB) => [keyA, keyB]),
    )
  }
  values() {
    return [...this.root.values()].flatMap((map) => [...map.values()])
  }
  entries() {
    const me = this
    function* entries() {
      for (const [keyA, map] of me.root.entries()) {
        yield* [...map.entries()].map(([keyB, value]) => [keyA, keyB, value])
      }
    }
    return entries()
  }
  forEach(callback: (value: TV, keyA: TKA, keyB: TKB) => void) {
    for (const [keyA, map] of this.root.entries()) {
      for (const [keyB, value] of map.entries()) {
        callback(value, keyA, keyB)
      }
    }
  }
}

Note: all the methods of the PairMap class have been tested using a harness I wrote, see here

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.