37

I'm looking for a way to freeze native ES6 Maps.

Object.freeze and Object.seal don't seem to work:

let myMap = new Map([["key1", "value1"]]);
// Map { 'key1' => 'value1' }

Object.freeze(myMap);
Object.seal(myMap);

myMap.set("key2", "value2");
// Map { 'key1' => 'value1', 'key2' => 'value2' }

Is this intended behavior since freeze freezes properties of objects and maps are no objects or might this be a bug / not implemented yet?

And yes I know, I should probably use Immutable.js, but is there any way to do this with native ES6 Maps?

Jo Liss
  • 30,333
  • 19
  • 121
  • 170
Tieme
  • 62,602
  • 20
  • 102
  • 156
  • 1
    related: [Is there a way to Object.freeze() a JavaScript Date?](http://stackoverflow.com/q/34907311/1048572) – Bergi Mar 02 '16 at 17:25
  • (I first thought your question is a duplicate, but couldn't find any until I searched for the problem without `Map`) – Bergi Mar 02 '16 at 17:26
  • Related: [tc39/proposal-readonly-collections](https://github.com/tc39/proposal-readonly-collections) – starball Jan 13 '23 at 00:57

7 Answers7

18

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
  • 156,129
  • 30
  • 331
  • 251
  • 7
    …but of course that won't stop anyone from doing `Map.prototype.clear.call(supposedlyFrozenMap)` – Bergi Mar 02 '16 at 17:28
  • 2
    @Bergi True, I was assuming the objective was to avoid accidental mutation, not true freezing. You'd need to do the wrapper class for that. – loganfsmyth Mar 02 '16 at 18:27
  • Objective was indeed avoid accidental mutation during tests. But indeed using babel.. will take another look to see if I can migrate whole codebase to immutable. Thanks! – Tieme Mar 03 '16 at 08:30
  • 2
    While I like the idea of silently returning, it would be better to emulate the original return types, so `this` for set(), `false` (?) for delete() and `undefined` for clear. – ccprog May 10 '17 at 20:03
  • I would recommend using isExtensible, not isFrozen. Although frozen seems like a better conceptual match on the surface, the “pseudo properties” of [[MapData]] will never be able to participate in algorithms like TestIntegrityLevel — at least [[IsExtensible]] just concerns a boolean flag. Consider: `Object.isFrozen(Object.seal(new Map([ [ 1, 2 ] ])))` is true but `Object.isFrozen(Object.seal(Object.assign(new Map([ [ 1, 2 ] ]), { x: 1 })))` is false. The fact that frozenness/sealedness chiefly describe the state of property descriptors puts some weirdness into attempts to apply them this way. – Semicolon Aug 28 '18 at 07:25
  • I thought this was an interesting approach, so I built a version that doesn't extend `Map` but simply reimplements the map API by composition with an internal private map, so `Map.prototype.clear.call(frozenMap)` will throw a `TypeError`. – Domino Jul 16 '21 at 23:34
14

@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);
}
Community
  • 1
  • 1
Tieme
  • 62,602
  • 20
  • 102
  • 156
  • 1
    Looks fine, though I'd omit the `instanceof` check as you called it `freezeMap` already. And you could cache the methods (for minuscule memory space improvements) outside, instead of recreating them for every call. And of course it still has the same "flaw" as logan's solution, not preventing `Map.prototype.clear.call(myMap)`. None of these really matter, but you asked for my thoughts :-) – Bergi Mar 04 '16 at 14:51
  • Fair points! Always looking for improvements, so thanks :). And yeah, that "flaw" is still there but that doesn't matter much to me since I'll use it only for testing. – Tieme Mar 06 '16 at 11:13
  • `mapClear` should have NO parameter. – Smartkid Feb 08 '17 at 05:26
  • 2
    Couldn’t you get past this by `Map.prototype.set.call(myMap, 'key', 'value')`? I tested and you can get past your freeze function this way. To actually make a frozen view, you’d need to return an object wrapper which only references the underlying map via captures and provides only the read methods. – binki Jun 02 '17 at 03:18
2

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.
Domino
  • 6,314
  • 1
  • 32
  • 58
  • The FreezableMap option here was inspired by loganfsmyth's answer and the comments on it. – Domino Jul 16 '21 at 23:13
  • Thanks for your elaborate answer. I havent wrote any javascript in a while, not sure if I can review the answer properly any more. Any JS moderator: please accept this answer if it's a working solution! – Tieme Jul 27 '21 at 09:36
0

If anyone is looking for a TypeScript version of the accepted answer:

export type ReadonlyMap<K,V> = Omit<Map<K,V>, "set"| "delete"| "clear">

export function freeze<K, V>(map: Map<K, V>): ReadonlyMap<K, V> {
  if (map instanceof Map) {
    map.set = (key: K) => {
      throw new Error(`Can't set property ${key}, map is not extensible`);
    };

    map.delete = (key: K) => {
      throw new Error(`Can't delete property ${key}, map is not extensible`);
    };

    map.clear = () => {
      throw new Error("Can't clear map, map is frozen");
    };
  }

  return Object.freeze(map);
}
mrcrowl
  • 1,265
  • 12
  • 14
-1

Sorry, I am not able to comment. I just wanted to add my typescript variant

const mapSet = function (key: unknown) {
  throw "Can't add property " + key + ', map is not extensible';
};

const mapDelete = function (key: unknown) {
  throw "Can't delete property " + key + ', map is frozen';
};

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

function freezeMap<T extends Map<K, V>, K, V>(myMap: T) {
  myMap.set = mapSet;
  myMap.delete = mapDelete;
  myMap.clear = mapClear;

  Object.freeze(myMap);

  return myMap;
}
user1632355
  • 99
  • 1
  • 4
  • The new methods should have a return type of `never` and the return type of `freezeMap` should be changed accordingly. As it stands the type annotations in this code add absolutely nothing and the type system still believes the frozen map is mutable. – Domino Jul 16 '21 at 17:47
-1

So applying ES6 make the code looks clearer. From my perspective :)

class FreezeMap extends Map {
    /**
     * @param {Map<number, any>} OriginalMap
     * @return {Map<number, any>}
     */
    constructor(OriginalMap) {
        super();
        OriginalMap.set = this.set.bind(OriginalMap);
        OriginalMap.delete = this.delete.bind(OriginalMap);
        OriginalMap.clear = this.clear.bind(OriginalMap);
        Object.freeze(OriginalMap);
        return OriginalMap;
    };

    set(key) {
        throw new Error(`Can't add property ${key}, map is not extensible`);
    };

    delete(key) {
        throw new Error(`Can't delete property ${key}, map is frozen`);
    };

    clear() {
        throw new Error(`Can't clear map, map is frozen`);
    };
}
Edgar Huynh
  • 101
  • 1
  • 3
  • This code adds a lot of unnecessary overhead and is very counter-intuitive as it makes it seem like `FreezeMap` is a derived class, but the `return` statement in the constructor makes it return the original instance. – Domino Jul 16 '21 at 17:36
  • Sometime you have to work with stupid IDE such as WebStorm, and this is the way I do for WebStorm to Resolved the method when I clicked into the Function property – Edgar Huynh Jul 21 '21 at 02:42
-1

a way to immune Map from changes by others

in class:

   class ValueSlider {
       static #slMapAsFunc = (function(_privateMap) {
           this.get =       Map.prototype.get.bind(_privateMap);
           this.set = this.delete = this.clear = () => {};
           this.has =       Map.prototype.has.bind(_privateMap);
           this.entries =   Map.prototype.entries.bind(_privateMap);
           this.forEach =   Map.prototype.forEach.bind(_privateMap);
           this.keys =      Map.prototype.keys.bind(_privateMap);
           this.values =    Map.prototype.values.bind(_privateMap);
        });
    
        static #_sliderMap = new Map() ; // for use internally in this class 
        static #slMapAsFuncInst = new this.#slMapAsFunc( this.#_sliderMap );

        /* for consumers */
        static get sliderMap() {
            return this.#slMapAsFuncInst;
        }

       constructor() {
           const statics = this.constructor;
           statics.#_sliderMap.set( nameInit, this); // add instance
           this.value = 9;
       }
   }

for consumer

function c() {
    /* this is not possible
    Map.prototype.clear.apply( ValueSlider.sliderMap._privateMap, [] );
    Map.prototype.clear.apply( ValueSlider.sliderMap, [] );
    */

    ValueSlider.sliderMap.forEach( (instance, key, map) => {
    /* this works */
        console.log(`value is ${instance.value}`;
    }
}
Erhy
  • 92
  • 3
  • 8