Value Objects as Map Keys in TypeScript

In a previous post I wrote about how JavaScript’s lack of support for value objects caused a problem when using the built-in Set class, and how it could be solved by using the idea of a Compound Set instead. In this post I’m going to describe a similar solution I’ve been using for the built-in Map class.

What’s the Problem?

At the time of writing, JavaScript doesn’t support immutability out of the box. Without it, we’re unable to assert the equality of objects from their value alone (using native features, at least).

Consider the following example in TypeScript:

type Point = {
  x: number;
  y: number;
};

const points = new Map<Point, string>();

points.set({ x: 0, y: 0 }, "The origin");

So far, so good, but unfortunately the following line produces an unexpected result:

points.get({ x: 0, y: 0 }); // undefined

This happens because JavaScript compares objects by reference, not by value, and the object passed to set() has a different reference in memory to the object passed to get().

Ideally, the Point type would describe a value object whose identity is defined by the values of its properties, but JavaScript doesn’t provide this kind of equality for objects.

Third Party Libraries

There are several popular libraries that add support for immutability. Check out immutable-js or immer, for example. I would encourage the use of these in most applications because immutability generally leads to less error-prone code. However, I’m not going to talk about these here because they’re already well documented and written about elsewhere.

I want to share a simple idea I’ve been using as a drop-in replacement for Map in situations where I don’t want to bloat my workspace with dependencies.

The Compound Map

In a similar way to the Compound Set linked above, we can define our own Compound Map class as an alternative to the built-in Map. The class should implement the same interface so that it can be used as a straightforward replacement.

The basic idea is to convert the map keys to deterministic strings and use these with the built-in Map instead, since comparison of scalar values behaves as expected.

Here’s the class in full (also in this GitHub Gist):

CompoundMap.ts
export default class CompoundMap<K, V> implements Map<K, V> {
  private readonly items: Map<string, { key: K; value: V }>;

  constructor(entries: [K, V][] = []) {
    this.items = new Map(
      entries.map(([key, value]) => [this.toKey(key), { key, value }])
    );
  }

  clear(): void {
    this.items.clear();
  }

  delete(key: K): boolean {
    return this.items.delete(this.toKey(key));
  }

  get(key: K): V | undefined {
    return this.items.get(this.toKey(key))?.value;
  }

  has(key: K): boolean {
    return this.items.has(this.toKey(key));
  }

  set(key: K, value: V): this {
    this.items.set(this.toKey(key), { key, value });
    return this;
  }

  *[Symbol.iterator](): IterableIterator<[K, V]> {
    for (const [, { key, value }] of this.items) {
      yield [key, value];
    }
  }

  *entries(): IterableIterator<[K, V]> {
    yield* this[Symbol.iterator]();
  }

  *keys(): IterableIterator<K> {
    for (const [, { key }] of this.items) {
      yield key;
    }
  }

  *values(): IterableIterator<V> {
    for (const [, { value }] of this.items) {
      yield value;
    }
  }

  forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void {
    for (const [, { key, value }] of this.items) {
      callbackfn.call(thisArg, value, key, this);
    }
  }

  get size(): number {
    return this.items.size;
  }

  get [Symbol.toStringTag](): string {
    return this.constructor.name;
  }

  private toKey(key: K): string {
    return JSON.stringify(key);
  }
}

Reworking the example above, we can now make sense of the points.get() call:

import CompoundMap from "./CompoundMap";

const points = new CompoundMap<Point, string>();

points.set({ x: 0, y: 0 }, "The origin");

points.get({ x: 0, y: 0 }); // The origin

Peeking into the Future

As mentioned in the post linked above, there’s a potential built-in solution to this problem not too far away. The Records & Tuples Proposal aims to introduce deeply immutable data structures, allowing for the following new syntax:

points.set(#{ x: 0, y: 0 }, "The origin");

Notice the # symbol preceding the object literal. This is how the proposal intends to signify the object should be immutable, which would allow for value based equality testing.

This will be a great step forward for the JavaScript spec, but until that happens I’ll continue to use CompoundMap (and CompoundSet) in situations that don’t warrant pulling in larger dependencies.