How do I persist a ES6 Map in localstorage (or elsewhere)?

29,525

Solution 1

Assuming that both your keys and your values are serialisable,

localStorage.myMap = JSON.stringify(Array.from(map.entries()));

should work. For the reverse, use

map = new Map(JSON.parse(localStorage.myMap));

Solution 2

Clean as a whistle:

JSON.stringify([...myMap])

Solution 3

Usually, serialization is only useful if this property holds

deserialize(serialize(data)).get(key) ≈ data.get(key)

where a ≈ b could be defined as serialize(a) === serialize(b).

This is satisfied when serializing an object to JSON:

var obj1 = {foo: [1,2]},
    obj2 = JSON.parse(JSON.stringify(obj1));
obj1.foo; // [1,2]
obj2.foo; // [1,2] :)
JSON.stringify(obj1.foo) === JSON.stringify(obj2.foo); // true :)

And this works because properties can only be strings, which can be losslessly serialized into strings.

However, ES6 maps allow arbitrary values as keys. This is problematic because, objects are uniquely identified by their reference, not their data. And when serializing objects, you lose the references.

var key = {},
    map1 = new Map([ [1,2], [key,3] ]),
    map2 = new Map(JSON.parse(JSON.stringify([...map1.entries()])));
map1.get(1); // 2
map2.get(1); // 2 :)
map1.get(key); // 3
map2.get(key); // undefined :(

So I would say in general it's not possible to do it in an useful way.

And for those cases where it would work, most probably you can use a plain object instead of a map. This will also have these advantages:

  • It will be able to be stringified to JSON without losing key information.
  • It will work on older browsers.
  • It might be faster.

Solution 4

Building off of Oriol's answer, we can do a little better. We can still use object references for keys as long as the there is primitive root or entrance into the map, and each object key can be transitively found from that root key.

Modifying Oriol's example to use Douglas Crockford's JSON.decycle and JSON.retrocycle we can create a map that handles this case:

var key = {},
    map1 = new Map([ [1, key], [key, 3] ]),
    map2 = new Map(JSON.parse(JSON.stringify([...map1.entries()]))),
    map3 = new Map(JSON.retrocycle(JSON.parse(JSON.stringify(JSON.decycle([...map1.entries()])))));
map1.get(1); // key
map2.get(1); // key
map3.get(1); // key
map1.get(map1.get(1)); // 3 :)
map2.get(map2.get(1)); // undefined :(
map3.get(map3.get(1)); // 3 :)

Decycle and retrocycle make it possible to encode cyclical structures and dags in JSON. This is useful if we want to build relations between objects without creating additional properties on those objects themselves, or want to interchangeably relate primitives to objects and visa-versa, by using an ES6 Map.

The one pitfall is that we cannot use the original key object for the new map (map3.get(key); would return undefined). However, holding the original key reference, but a newly parsed JSON map seems like a very unlikely case to ever have.

Solution 5

If you implement your own toJSON() function for any class objects you have then just regular old JSON.stringify() will just work!

Maps with Arrays for keys? Maps with other Map as values? A Map inside a regular Object? Maybe even your own custom class; easy.

Map.prototype.toJSON = function() {
    return Array.from(this.entries());
};

That's it! prototype manipulation is required here. You could go around adding toJSON() manually to all your non-standard stuff, but really you're just avoiding the power of JS

DEMO

test = {
    regular : 'object',
    map     : new Map([
        [['array', 'key'], 7],
        ['stringKey'     , new Map([
            ['innerMap'    , 'supported'],
            ['anotherValue', 8]
        ])]
    ])
};
console.log(JSON.stringify(test));

outputs:

{"regular":"object","map":[[["array","key"],7],["stringKey",[["innerMap","supported"],["anotherValue",8]]]]}

Deserialising all the way back to real Maps isn't as automatic, though. Using the above resultant string, I'll remake the maps to pull out a value:

test2 = JSON.parse(JSON.stringify(test));
console.log((new Map((new Map(test2.map)).get('stringKey'))).get('innerMap'));

outputs

"supported"

That's a bit messy, but with a little magic sauce you can make deserialisation automagic too.

Map.prototype.toJSON = function() {
    return ['window.Map', Array.from(this.entries())];
};
Map.fromJSON = function(key, value) {
    return (value instanceof Array && value[0] == 'window.Map') ?
        new Map(value[1]) :
        value
    ;
};

Now the JSON is

{"regular":"object","test":["window.Map",[[["array","key"],7],["stringKey",["window.Map",[["innerMap","supported"],["anotherValue",8]]]]]]}

And deserialising and use is dead simple with our Map.fromJSON

test2 = JSON.parse(JSON.stringify(test), Map.fromJSON);
console.log(test2.map.get('stringKey').get('innerMap'));

outputs (and no new Map()s used)

"supported"

DEMO

Share:
29,525

Related videos on Youtube

Letharion
Author by

Letharion

Updated on September 25, 2021

Comments

  • Letharion
    Letharion over 2 years
    var a = new Map([[ 'a', 1 ]]);
    a.get('a') // 1
    
    var forStorageSomewhere = JSON.stringify(a);
    // Store, in my case, in localStorage.
    
    // Later:
    var a = JSON.parse(forStorageSomewhere);
    a.get('a') // TypeError: undefined is not a function
    

    Unfortunatly JSON.stringify(a); simply returns '{}', which means a becomes an empty object when restored.

    I found es6-mapify that allows up/down-casting between a Map and a plain object, so that might be one solution, but I was hoping I would need to resort to an external dependency simply to persist my map.

  • Letharion
    Letharion about 9 years
    Since I do initialize the Map with an array, you'd think I would have though of this myself. :) Thanks.
  • Letharion
    Letharion over 8 years
    Very interesting. +1 I'm not currently working on this project any more, but I'll keep this in mind for the future. While it's been a while, so I don't exactly remember why I picked a map, I believe it had something to do with it fitting precisely to my use case, which I imagined would make it a fast option as well. Could you elaborate on the "might be faster" bit? :)
  • Oriol
    Oriol over 8 years
    @Letharion I remember reading that a certain version version of Firefox detected when all keys in a map are strings, and then it could be optimized more (like objects). Then I guess maps will be slower on browsers without this kind of optimization. But that's implementation-dependent, and I haven't done any test to measure performance.
  • corse32
    corse32 over 6 years
    I would suggest that if more Stack Overflow answers had the form, and quality of this one - and moreover be as understatedly complete in their degree of utility - we should have quite the excellent JavaScript resource.
  • Emanuele Feliziani
    Emanuele Feliziani about 6 years
    This is just splendid. I wish I could upvote more than once. For those reading, you can then deserialise it by simply doing: let deserialized = new Map(JSON.parse(serialisedMap));
  • bmaupin
    bmaupin about 6 years
    Just tried this in Chrome and .entries() wasn't necessary; this seemed to do the same thing: localStorage.myMap = JSON.stringify(Array.from(map));
  • Bergi
    Bergi about 6 years
    @bmaupin Yes, the default [Symbol.iterator] that Array.from uses is the same as .entries on a Map, but I wanted to spell it out so that it's obvious we are using an iterator here.
  • Mordred
    Mordred over 2 years
    If doing this in typescript, you'll need to add "downlevelIteration": true, to your compilerOptions in tsconfig.json, otherwise it will throw errors.