Is there a way to freeze an ES6 Map?

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.

Solution A: 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(); }
}

Solution B: 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. It requires a bit more work, but the result is more flexible since it also lets us support Object.seal and Object.preventExtensions.

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.

There is not, you could write a wrapper to do that. Object.freeze locks an object's properties, but while Map instances are objects, the values they store are not properties, so freezing has no effect on them, just like any other class that has internal state hidden away.

In a real ES6 environment where extending builtins is supported (not Babel), you could do this:

class FreezableMap extends Map {
    set(...args){
        if (Object.isFrozen(this)) return this;

        return super.set(...args);
    }
    delete(...args){
        if (Object.isFrozen(this)) return false;

        return super.delete(...args);
    }
    clear(){
        if (Object.isFrozen(this)) return;

        return super.clear();
    }
}

If you need to work in ES5 environments, you could easily make a wrapper class for a Map rather than extending the Map class.


@loganfsmyth, your answer gave me an idea, what about this:

function freezeMap(myMap){

  if(myMap instanceof Map) {

    myMap.set = function(key){
      throw('Can\'t add property ' + key + ', map is not extensible');
    };

    myMap.delete = function(key){
      throw('Can\'t delete property ' + key + ', map is frozen');
    };

    myMap.clear = function(){
      throw('Can\'t clear map, map is frozen');
    };
  }

  Object.freeze(myMap);
}

This works perfectly for me :)


Updated with points from @Bergi in the comments:

var mapSet = function(key){
  throw('Can\'t add property ' + key + ', map is not extensible');
};

var mapDelete = function(key){
  throw('Can\'t delete property ' + key + ', map is frozen');
};

var mapClear = function(){
  throw('Can\'t clear map, map is frozen');
};

function freezeMap(myMap){

  myMap.set = mapSet;
  myMap.delete = mapDelete;
  myMap.clear = mapClear;

  Object.freeze(myMap);
}