Blog

JavaScript Private Data With WeakMap

A class field in JavaScript is public. Anyone holding the object can read it, write it, or print it by accident in a log line. Sometimes that is fine. When the field is internal bookkeeping, it is not.

One way to keep state out of reach is to store it outside the object, in a WeakMap keyed by the instance itself. The object holds nothing. The map holds everything, and only code with a reference to the map can read it.

The problem

Here is a counter with an ordinary field. The field is part of the object, so it can be overwritten from outside.

// the private field problem
class Counter1 {
  constructor() { this.count = 0; }
  increment() { this.count++; return this.count; }
}
const c1 = new Counter1();
c1.count = 999;                       // anyone can do this
console.log(c1.increment());

It prints:

1000

One assignment from outside and the count is wrong. Nothing in the class can stop it.

Move the state into a WeakMap

Now the same counter, with its state in a module level WeakMap. The key is this, so every instance gets its own entry.

// WeakMap version
const privates = new WeakMap();
class Counter2 {
  constructor() { privates.set(this, { count: 0 }); }
  increment() {
    const state = privates.get(this);
    state.count++;
    return state.count;
  }
}
const c2 = new Counter2();
c2.count = 999;                       // creates a useless public property
console.log(c2.increment(), '| public count:', c2.count);
console.log('own keys ->', Object.keys(c2));
console.log('JSON     ->', JSON.stringify(c2));

It prints:

1 | public count: 999
own keys -> [ 'count' ]
JSON     -> {"count":999}

The write from outside still happens, but it lands on a public property that the class never reads. The real count is untouched. Notice the last two lines. The internal state does not show up in Object.keys and it does not show up in JSON.stringify, because it was never on the object.

Keys have to be objects

A WeakMap only accepts objects as keys. Strings and numbers are rejected.

// keys must be objects
try { new WeakMap().set('a string', 1); }
catch (err) { console.log(err.constructor.name + ':', err.message); }

It prints:

TypeError: Invalid value used as weak map key

That restriction is the point. The key has to be something the garbage collector can track.

You cannot list what is inside

A WeakMap has no keys(), no values(), no size, and it cannot be iterated.

// not enumerable, not iterable
const wm = new WeakMap();
const key = {};
wm.set(key, 'value');
console.log('has ->', wm.has(key), '| get ->', wm.get(key));
console.log('keys() exists?', typeof wm.keys);

It prints:

has -> true | get -> value
keys() exists? undefined

You can only ask about a key you already hold. This is also why the map does not keep objects alive. When the last reference to an instance goes away, its entry becomes eligible for collection on its own. A plain Map would hold that instance forever.

What to remember

  • A WeakMap keyed by this keeps per-instance state off the instance.
  • The state is invisible to Object.keys, JSON.stringify and the debugger view of the object.
  • Keys must be objects, and the map cannot be listed or counted.
  • Entries do not stop their keys from being garbage collected, which a Map would.

If you want the same privacy with syntax support instead of a side table, use #private class fields. The WeakMap version still matters when the state belongs to objects you do not own, such as instances from a library you cannot change.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.

Leave a Reply