Since Map and Set objects store their elements in internal slots, freezing them won't make them immutable. No matter the syntax used to extend or modify a Map object, its internal slots will still be mutable via Map.prototype.set. Therefore, the only way to protect a map is to not expose it directly to untrusted code.
Possible solution: Creating a read-only view for your map
You could create a new Map-like object that exposes a read-only view of your Map. For example:
function mapView (map) {
return Object.freeze({
get size () { return map.size; },
[Symbol.iterator]: map[Symbol.iterator].bind(map),
clear () { throw new TypeError("Cannot mutate a map view"); } ,
delete () { throw new TypeError("Cannot mutate a map view"); },
entries: map.entries.bind(map),
forEach (callbackFn, thisArg) {
map.forEach((value, key) => {
callbackFn.call(thisArg, value, key, this);
});
},
get: map.get.bind(map),
has: map.has.bind(map),
keys: map.keys.bind(map),
set () { throw new TypeError("Cannot mutate a map view"); },
values: map.values.bind(map),
});
}
A couple things to keep in mind about such an approach:
- The view object returned by this function is live: changes in the original Map will be reflected in the view. This doesn't matter if you don't keep any references to the original map in your code, otherwise you might want to pass a copy of the map to the
mapView
function instead.
- Algorithms that expect Map-like objects should work with a map view, provided they don't ever try to apply a
Map.prototype
method on it. Since the object is not an actual Map with internal slots, applying a Map method on it would throw.
- The content of the mapView cannot be inspected in dev tools easily.
Alternatively, one could define MapView
as a class with a private #map
field. This makes debugging easier as dev tools will let you inspect the map's content.
class MapView {
#map;
constructor (map) {
this.#map = map;
Object.freeze(this);
}
get size () { return this.#map.size; }
[Symbol.iterator] () { return this.#map[Symbol.iterator](); }
clear () { throw new TypeError("Cannot mutate a map view"); }
delete () { throw new TypeError("Cannot mutate a map view"); }
entries () { return this.#map.entries(); }
forEach (callbackFn, thisArg) {
this.#map.forEach((value, key) => {
callbackFn.call(thisArg, value, key, this);
});
}
get (key) { return this.#map.get(key); }
has (key) { return this.#map.has(key); }
keys () { return this.#map.keys(); }
set () { throw new TypeError("Cannot mutate a map view"); }
values () { return this.#map.values(); }
}
Hack: Creating a custom FreezableMap
Instead of simply allowing the creation of a read-only view, we could instead create our own FreezableMap
type whose set
, delete
, and clear
methods only work if the object is not frozen.
This is, in my honest opinion, a terrible idea. It takes an incorrect assumption (that frozen means immutable) and tries to make it a reality, producing code that will only reinforce that incorrect assumption. But it's still a fun thought experiment.
Closure version:
function freezableMap(...args) {
const map = new Map(...args);
return {
get size () { return map.size; },
[Symbol.iterator]: map[Symbol.iterator].bind(map),
clear () {
if (Object.isSealed(this)) {
throw new TypeError("Cannot clear a sealed map");
}
map.clear();
},
delete (key) {
if (Object.isSealed(this)) {
throw new TypeError("Cannot remove an entry from a sealed map");
}
return map.delete(key);
},
entries: map.entries.bind(map),
forEach (callbackFn, thisArg) {
map.forEach((value, key) => {
callbackFn.call(thisArg, value, key, this);
});
},
get: map.get.bind(map),
has: map.has.bind(map),
keys: map.keys.bind(map),
set (key, value) {
if (Object.isFrozen(this)) {
throw new TypeError("Cannot mutate a frozen map");
}
if (!Object.isExtensible(this) && !map.has(key)) {
throw new TypeError("Cannot add an entry to a non-extensible map");
}
map.set(key, value);
return this;
},
values: map.values.bind(map),
};
}
Class version:
class FreezableMap {
#map;
constructor (...args) {
this.#map = new Map(...args);
}
get size () { return this.#map.size; }
[Symbol.iterator] () { return this.#map[Symbol.iterator](); }
clear () {
if (Object.isSealed(this)) {
throw new TypeError("Cannot clear a sealed map");
}
this.#map.clear();
}
delete (key) {
if (Object.isSealed(this)) {
throw new TypeError("Cannot remove an entry from a sealed map");
}
return this.#map.delete(key);
}
entries () { return this.#map.entries(); }
forEach (callbackFn, thisArg) {
this.#map.forEach((value, key) => {
callbackFn.call(thisArg, value, key, this);
});
}
get (key) { return this.#map.get(key); }
has (key) { return this.#map.has(key); }
keys () { return this.#map.keys(); }
set (key, value) {
if (Object.isFrozen(this)) {
throw new TypeError("Cannot mutate a frozen map");
}
if (!Object.isExtensible(this) && !this.#map.has(key)) {
throw new TypeError("Cannot add an entry to a non-extensible map");
}
this.#map.set(key, value);
return this;
}
values () { return this.#map.values(); }
}
I hereby release this code to the public domain. Note that it has not been tested much, and it comes with no warranty.
Happy copy-pasting.
Map
) – Td