Fixing the under-appreciated JavaScript Map class and using it to construct a build system
JavaScript has a Map class for holding key/value pairs, but it’s underused and underappreciated. If you fix the class’ limitations, you can use Map as a building block to create really interesting things.
Photo: Cun Mo, Unsplash
A quick review of Map
The Map class lets you associate 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"
m.values(); // 1, 2
The Map class has a number of advantages over plain objects for storing data, but JavaScript syntax makes it easier to create and work with objects so Map doesn’t get used as often as it should.
Extending Map
Map also has one key advantage over a plain object: Map is a class you can extend, so you can expose any key/value data store as a Map.
For example, we can write a FileMap class that makes the contents of a file system folder available as a Map. This ignores the map’s built-in storage, and instead gets the keys and values from the file system:
import * as fs from "node:fs";
import path from "node:path";
export default class FileMap extends Map {
constructor(dirname) {
super();
this.dirname = path.resolve(process.cwd(), dirname);
}
get(key) {
const filePath = path.resolve(this.dirname, key);
let stats;
try {
stats = fs.statSync(filePath);
} catch (error) {
if (error.code === "ENOENT") {
return undefined; // File not found
}
throw error;
}
return stats.isDirectory()
? new this.constructor(filePath) // Return subdirectory as a map
: fs.readFileSync(filePath); // Return file contents
}
*keys() {
try {
yield* fs.readdirSync(this.dirname);
} catch (error) {
if (error.code === "ENOENT") {
// Directory doesn't exist yet; treat as empty
} else {
throw error;
}
}
}
}
This is pretty amazing! You can easily work with files in code.
const markdown = new FileMap("./markdown");
markdown.keys(); // ["post1.md", "post2.md", …]
markdown.get("post1.md"); // "This is **post 1**."
Most of the time you work with files in code, you’re just reading a list of files and getting their contents, so something like this is much easier to work with than the full file system API.
Better yet, you can immediately pass this kind of file system Map to any code that understands a Map. That provides a desired separation between data storage and code that works on data.
Limitations to work around
Sadly, the Map class is cumbersome to extend: its standard methods like clear(), entries(), and values() will ignore your get() and keys() methods.
markdown.entries(); // empty array ☹️
For comparison, the Python language provides a Mapping abstract base class that is much more helpful than JavaScript’s Map. When you inherit from Mapping, you only need to define a small set of core methods, and the base class uses your definitions to provide the remaining methods.
To compensate for this and other issues with Map, we can create our own base class that inherits from Map but provides all the expected methods:
export default class BetterMapBase extends Map {
// Override entries() method to call overridden get() and keys()
*entries() {
for (const key of this.keys()) {
const value = this.get(key);
yield [key, value];
}
}
…
}
If we derive our FileMap class from this improved base class, the full set of Map methods work as expected:
markdown.entries(); // [["post1.md", <contents>], …] 🤩
Transforming a map
Once your data is in Map form, you can manipulate it in interesting ways without having to worry about how the original data is defined. For example, you can transform a map of markdown to a map of HTML.
const html = new HtmlMap(markdown);
html.keys(); // ["post1.html", "post2.html", …]
html.get("post1.html"); // "<p>This is <strong>post 1</strong>.</p>\n"
Transforming the values of data often implies a transformation of the keys, so maps are ideal representations of such operations.
Creating a build system with maps
This Map approach is a pattern you can explore in more detail. Because it’s a pattern, you can use it without taking on any new dependencies.
But you can take advantage of the Web Origami project’s async-tree library, which includes:
- A better
Mapbase class along these lines, calledSyncMap - An asynchronous variant called
AsyncMapthat can represent network data sources - A more complete
FileMapclass that supports write operations - A large collection of map-based operations for concisely expressing transformations like the one shown above
You can use this library to construct things like a build system for generating the static files for a site. A sample blog project uses the library to represent the various stages of the build process as Map-based trees:
- The source content in the file system is represented as a tree of
Mapobjects. - These are transformed with map-based operations and composed into the desired tree of site resources.
- The site tree is copied directly into a
Map-based representation of the folder that will hold the build output.
The entire build.js build process is very concise and boils down to a copy operation:
import { FileMap, Tree } from "@weborigami/async-tree";
import site from "./site.js";
// Build process writes the site resources to the build folder
const buildTree = new FileMap(new URL("../build", import.meta.url).pathname);
await Tree.clear(buildTree); // Erase any existing files
await Tree.assign(buildTree, site); // Copy site to build folder
By using Map as the fundamental building block, this sample project generates the site’s static files using only two dependencies, the async-tree library and a markdown processor.
The AsyncMap variant lets you represent a network data source as an object that has the same methods as a Map but the methods are asynchronous. This lets you pull content from sources like Dropbox or Google Drive using a much simpler API, so you can incorporate content directly from the network into your build process.