The Map interface

The last section noted that, in the context of our markdown-to-HTML problem, it’s possible to conceptualize the markdown content as a map:

g post1.md This is **post 1**. ->post1.md post1.md post2.md This is **post 2**. ->post2.md post2.md post3.md This is **post 3**. ->post3.md post3.md

We can use an interface to represent such a map, regardless of the data’s underlying representation.

The standard Map class #

JavaScript provides a standard Map class for the express purpose of associating keys with values.

const m = new Map();

m.add("a", 1);
m.add("b", 2);

m.get("a"); // 1
m.get("b"); // 2
m.get("c"); // undefined

m.keys(); // "a", "b"

If you haven’t worked with the Map class before, it’s a little like an array holding little arrays, each of which pairs a key with a value. The above Map is effectively the same as:

[
  ["a", 1],
  ["b", 2],
];

We can pass such an array-of-arrays to the Map constructor to populate it with an initial set of entries. We could initialize a Map with our sample post data:

const m = new Map([
  ["post1.md", "This is **post 1**."],
  ["post2.md", "This is **post 2**."],
  ["post3.md", "This is **post 3**."],
]);

m.get("post1.md"); // "This is **post 1**."

One thing that makes Map more interesting than an array-of-arrays is that Map is much faster at finding a given key and the value associated with it. Another point that’s vital for our purposes is that Map is a class whose members can be overridden.

Map as an interface #

We can co-opt Map into working as a general-purpose interface for accessing information stored elsewhere.

Specifically, we can subclass Map to create a custom class that looks and works just like Map but is backed by other data. The Map class happens to come with built-in storage — but we just ignore that.

That is, we will use Map as an interface: a defined set of consistently-named methods and properties that meet specific expectations. Any code written to work with Map will automatically work with our custom subclasses without modification.

The standard Map class includes two core methods of special interest:

  • A get method which gets the value for a given key. If we ask the above map for post1.md, we want to get back This is **post 1**. Here the value we get is text, but like the keys, the values can be of any data type.
  • A keys method which produces the keys of the map. In the map above, the keys are post1.md, post2.md, and post3.md. The keys will often be strings, but don’t have to be strings.

Overriding these two Map methods will form the basis of the Map Tree pattern. The basic shape of our code will look like:

class CustomMap extends Map {
  get(key) {
    // Return the value of the given key
  }

  *keys() {
    // Yield the available keys
  }
}

The keys method is slightly exotic, returning an iterator that can produce a sequence of values.

  • The simplest way to create an iterator is writing a generator function. A generator definition starts with an asterisk, like *keys.
  • The simplest way to consume an iterator is to pass it to Array.from, which will enumerate the values produced by the iterator and return those as an array.

Create a map from an object #

Of the three data representations we looked at previously, the in-memory JavaScript object was the simplest, so let’s first look at defining a Map subclass that’s backed by an object’s data:

/* src/map/ObjectMap.js */

export default class ObjectMap extends Map {
  constructor(object) {
    super();
    this.object = object;
  }

  get(key) {
    return this.object[key];
  }

  *keys() {
    yield* Object.keys(this.object);
  }
}

This lets us work with an existing object as if it were a Map. To be clear: we’re not copying that object’s keys and values into a Map — we’re creating a Map that wraps the object. This is a fast operation.

This ObjectMap class isn’t finished yet, but this is sufficient for us to begin playing with it. We can instantiate this class to wrap an object containing the markdown data:

/* src/map/object.js */

import ObjectMap from "./ObjectMap.js";

export default new ObjectMap({
  "post1.md": "This is **post 1**.",
  "post2.md": "This is **post 2**.",
  "post3.md": "This is **post 3**.",
});

Test the object map #

The first thing we can do with this object-based map is programmatically verify that its get and keys methods conform to the expectations of the Map class.

We’re going to run these tests against other types of maps, so we’ll generalize them:

/* src/map/mapTest.js */

import assert from "node:assert";
import { describe, test } from "node:test";

// Given a map instance, run a test suite against it
export default function (map) {
  describe(map.constructor.name, () => {
    test("get", () => {
      assert.equal(map.get("post1.md"), "This is **post 1**.");
      assert.equal(map.get("xyz"), undefined);
    });

    test("keys", () => {
      const keys = map.keys();
      assert(keys instanceof Iterator);
      assert.deepEqual(Array.from(keys), ["post1.md", "post2.md", "post3.md"]);
    });
  });
}

And then used the general tests with our ObjectMap implementation:

/* src/map/object.test.js */

import mapTest from "./mapTest.js";
import objectMap from "./object.js";

mapTest(objectMap);

From inside the src/map directory, run these tests to see that all test pass:

$ cd ../map
$ node object.test.js
▶ ObjectMap
  ✔ get
  ✔ keys
✔ ObjectMap
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
…

 

Next: Display a map »