Is there a way to freeze an ES6 Map?
Asked Answered
I

6

43

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?

Izolaiztaccihuatl answered 2/3, 2016 at 12:30 Comment(3)
related: Is there a way to Object.freeze() a JavaScript Date?Td
(I first thought your question is a duplicate, but couldn't find any until I searched for the problem without Map)Td
Related: tc39/proposal-readonly-collectionsGregarious
I
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);
}
Izolaiztaccihuatl answered 3/3, 2016 at 15:31 Comment(4)
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 :-)Td
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.Izolaiztaccihuatl
mapClear should have NO parameter.Carricarriage
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.Brogue
S
19

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.

Spoken answered 2/3, 2016 at 16:56 Comment(6)
…but of course that won't stop anyone from doing Map.prototype.clear.call(supposedlyFrozenMap)Td
@Td True, I was assuming the objective was to avoid accidental mutation, not true freezing. You'd need to do the wrapper class for that.Spoken
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!Izolaiztaccihuatl
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.Prairial
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.Blockade
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.Suburbia
I
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);
}
Izolaiztaccihuatl answered 3/3, 2016 at 15:31 Comment(4)
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 :-)Td
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.Izolaiztaccihuatl
mapClear should have NO parameter.Carricarriage
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.Brogue
S
3

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.
Suburbia answered 16/7, 2021 at 18:25 Comment(2)
The FreezableMap option here was inspired by loganfsmyth's answer and the comments on it.Suburbia
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!Izolaiztaccihuatl
T
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);
}
Tongue answered 2/4, 2022 at 4:11 Comment(0)
G
-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`);
    };
}
Garlan answered 22/6, 2021 at 11:7 Comment(2)
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.Suburbia
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 propertyGarlan
P
-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}`;
    }
}
Pronator answered 7/11, 2022 at 18:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.