To support the use of Map as an interface, the async-tree library provides a base class called SyncMap. SyncMap is designed as a drop-in replacement for Map, and avoids a number of problems with extending Map directly; see below.
As its name suggests, all of the members of SyncMap are synchronous; for an asynchronous version, see AsyncMap.
Problems extending the standard Map class #
The interface for Map is straightforward, so it would be nice to subclass Map to create new classes that behave just like Map and can be used anywhere Map can be used. Unfortunately, the standard Map class has a number of limitations that make it cumbersome to extend.
Map built-in members don’t respect overridden methods #
The Map class members can be divided into two categories:
- Core methods that uniquely define the behavior of a map:
delete(),get(),keys(),set() - Helper properties and methods that can be defined in terms of the core methods:
clear(),entries(),forEach(),has(),size,values(), and[Symbol.iterator]().
It would be nice if the helper members were actually defined in terms of the core methods, so that overriding a core method would automatically affect the behavior of the helper members.
For example, the clear() helper method erases everything in the Map. It would ideally be defined in terms of the core methods keys() and delete(). Similarly, the entries() helper method returns the [key, value] pairs of the map, and would ideally be defined in terms of the core methods keys() and get().
Sadly, the Map helper members are hard-coded to work directly with the class’s internal data representation. Subclassing Map then requires boilerplate code to maintain baseline expectations.
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.
Map methods fail when the prototype chain is extended #
JavaScript generally allows you to extend an object’s prototype chain. This lets you, for example, create a variation of an original object that has some extra properties.
const x = { a: 1 };
const y = Object.create(x); // y extends x
y.b = 2;
x.a; // 1
x.b; // undefined, x was unaffected by y's change
y.a; // 1, inherited from x
y.b; // 2, extra property
Generally speaking, almost everywhere in JavaScript where you can use an object like x, you can call Object.create() to create a new object y and substitute that instead.
However, the standard Map breaks this useful behavior.
const m = new Map([["a", 1]]);
m.get("a"); // 1
const n = Object.create(m); // n extends m
n.get("a"); // TypeError: get method called on incompatible Object
The problem is that the standard Map methods specifically check that the object they’re applied to was created with a Map constructor. This limits the ways in which a Map can be used.
A Map is always read/write #
There are cases where a read-only version of a Map would be desirable, but there is no standard way to create such a Map and communicate the map’s read-only nature to consumers.
Fixing the problems in Map #
The SyncMap class is a subclass of Map that fixes the above problems to create a better base class for creating custom maps.
- Helper methods are defined in terms of core methods. For example, the
entries()method is defined in terms ofkeys()andget()as sketched earlier. - Instances inheriting from
SyncMapcan have their prototype chain extended viaObject.create()and methods likeget()will still work as expected. SyncMapdefines areadOnlyproperty which, if true, indicates the map’s destructivedelete()orset()methods should not be called.
Helper methods defined in terms of core methods #
If you override the core map methods, the SyncMap helper methods take advantage of them.
For example, clear() is essentially calling the core methods keys() and delete():
clear() {
for (const key of this.keys()) {
this.delete(key);
}
}
Similarly, entries() is essentially calling keys() and get():
*values() {
for (const key of this.keys()) {
yield this.get(key);
}
}
We can see these methods at work in a toy SyncMap subclass for a map of greetings:
class GreetingsMap extends SyncMap {
get(key) {
return `Hello, ${ key }!`;
}
*keys() {
yield* ["Alice", "Bob", "Carol"];
}
}
const m = new GreetingsMap();
m.entries();
// ["Alice", "Hello, Alice!"], ["Bob", "Hello, Bob!"], ["Carol", "Hello, Carol!"]]
Prototype chain extension #
Unlike a standard Map, a SyncMap can have its prototype chain extended:
const m = new SyncMap([["a", 1]]);
m.get("a"); // 1
const n = Object.create(m);
n.get("a"); // 1 as expected -- no more TypeError
Read-only maps #
SyncMap defines a readOnly property that you can inspect to determine whether a map supports writes. The readOnly property will be true if the class’s get() method has been overridden but delete() and set() have not.
class MyMap extends SyncMap {
get(key) {
return super.get(key);
}
}
const m = new MyMap();
m.readOnly; // true
To definitively indicate that a SyncMap subclass is read/write, provide implementations of the delete() and set() methods — even if all those methods do is call their super methods.
Maps backed by data sources #
One important use for SyncMap is to create Map-compatible objects that are backed by existing data from some source. For example, the FileMap class creates a SyncMap (and so a Map) backed by file system folders and files; the FunctionMap class is based by a function and an optional domain.
These SyncMap subclasses will create the internal key/value data stored used by Map but will not use it. That overhead is the price paid for the condition SyncMap instanceof Map to be true.
Separation of keys and values #
One ramification of defining maps based on data elsewhere is that there may not always be a tight relationship between the sizes of the map’s keys and values.
For example, consider the GreetingsMap shown earlier:
class GreetingsMap extends SyncMap {
get(key) {
return `Hello, ${ key }!`;
}
*keys() {
yield* ["Alice", "Bob", "Carol"];
}
}
This map exposes a fixed number of keys, but the get() method will actually return a defined value for an infinite number of keys.
const m = new GreetingsMap();
m.has("Alice"); // true
m.get("Alice"); // "Hello, Alice!"
m.has("Dave"); // false, not a defined key
m.get("Dave"); // "Hello, Dave!"
In certain circumstances, such a map can be very useful.
To account for this potential disconnect between keys and values, the map helper methods in SyncMap have the following default behavior:
clear(),entries(),forEach(),values(), and[Symbol.iterator]()loop over the result ofkeys().has(key)returnstrueas long as the givenkeyappears in the result ofkeys(). As shown above, the map might still return a value for akeyeven whenhas(key)isfalse.sizereturns the number of keys returned bykeys(). Theget()might actually return more values than that size would suggest.
Limitations #
SyncMap can be generally used as a drop-in replacement for Map. One situation where SyncMap will not work as expected is with the JavaScript function structuredClone. structuredClone will accept a Map object but will directly accesses a map’s built-in storage. If you pass a SyncMap to structuredClone, the function will not call your get() or keys() methods, and the cloned result will be an empty Map.
API #
A base class for creating custom Map subclasses for use in trees.
Instances of SyncMap (and its subclasses) pass instanceof Map, and all Map
methods have compatible signatures.
Subclasses may be read-only or read-write. A read-only subclass overrides get() but not set() or delete(). A read-write subclass overrides all three methods.
For use in trees, SyncMap instances may indicate a parent node. They can
also indicate children subtrees using the trailing slash convention: a key
for a subtree may optionally end with a slash. The get() and has() methods
support optional trailing slashes on keys.
new SyncMap(iterable)
- iterable: any
clear()
Returns: void
Removes all key/value entries from the map.
Unlike the standard Map.prototype.clear(), this method invokes an
overridden keys() and delete() to ensure proper behavior in subclasses.
If the readOnly property is true, calling this method throws a
TypeError.
delete(key)
- key: any
Returns: any
Removes the entry for the given key, return true if an entry was removed and false if there was no entry for the key.
If the readOnly property is true, calling this method throws a
TypeError.
entries()
Returns: MapIterator<[any, any]>
Returns a new Iterator object that contains a two-member array of [key,
value] for each element in the map in insertion order.
Unlike the standard Map.prototype.clear(), this method invokes an
overridden keys() and get() to ensure proper behavior in subclasses.
forEach(callback, [thisArg])
- callback: (value: any, key: any, thisArg: any) => void
- thisArg: any
Returns: void
Calls callback once for each key/value pair in the map, in insertion order.
Unlike the standard Map.prototype.forEach(), this method invokes an
overridden entries() to ensure proper behavior in subclasses.
get(key)
- key: any
Returns: any
Returns the value associated with the key, or undefined if there is none.
has(key)
- key: any
Returns: any
Returns true if the given key appears in the set returned by keys().
It doesn’t matter whether the value returned by get() is defined or not.
If the requested key has a trailing slash but has no associated value, but the alternate form with a slash does appear, this returns true.
keys()
Returns: MapIterator
Returns a new Iterator object that contains the keys for each element in
the map in insertion order.
parent
Type: SyncMap
The parent of this node in a tree.
readOnly
Type: boolean
True if the object is read-only. This will be true if the get() method has
been overridden but set() and delete() have not.
set(key, value)
- key: any
- value: any
Returns: any
Adds a new entry with a specified key and value to this Map, or updates an existing entry if the key already exists.
If the readOnly property is true, calling this method throws a TypeError.
size
Type: any
Returns the number of keys in the map.
The size property invokes an overridden keys() to ensure proper
behavior in subclasses. Because a subclass may not enforce a direct
correspondence between keys() and get(), the size may not reflect the
number of values that can be retrieved.
values()
Returns: MapIterator<[any]>
Returns a new Iterator object that contains the values for each element
in the map in insertion order.