`new WeakMap(entries, { properties: true });`

This is an observation of the WeakMap API and my experiences using it. The core observation is: it was always possible for the engine to implement WeakMap in two different ways.

  1. As hash map with weakly-held keys. (what engines do)
  2. As hidden properties on objects.

This idea would allow the user to ask the engine for an instance of WeakMap backed by hidden properties on objects. The resulting WeakMap instance would be indistinguishable from a normal one at the API level.

This implies a new weak flag in each property descriptor, which would be necessary to prevent the weakmap's hidden property descriptors from becoming visible using reflection APIs. A WeakMap implemented this way would hold a Symbol which it would never expose through its publc APIs. This would be the key used in the weak property descriptor. Thus, only the holder of the weak map would be able to read and write this data. Since the symbol would be invisible to reflection, it would not be possible to gain access to it and forge it. The ability to read and write the hidden property could still be delegated using getter and setter functions.

Why allow this? Well, it's a performance optimization. The default implementation is safe to use freely because it doesn't alter any hidden classes. Altering the hidden classes can have disasterous implications for performance if it isn't done with intention and care. This is to say: I think it makes sense that the default implementation is what it is.

To make safe and proper use of a an implementation that alters the hidden class, you'd need to use it like this:

let integritySet = new WeakSet([], { properties: true });

export class ClassWithIntegrity {
  constructor() {
    this.value = 'OK'; // hidden class altered on this line
    integritySet.add(this); // hidden class altered on this line
    Object.freeze(this);
  }
}

Now you can do exactly this with the current WeakMap, and it works -- as I said, the APIs are identical. Unfortunately, the perf characteristics are not identical. If your usage is modeled on the cost of hidden properties in still-monomorphic objects, you will be sorely disappointed by the cost of maintaining a hash map, especially when the number of objects get into the millions. Hidden properties on monomorphic objects are still effortless from a perf standpoint, even with millions of objects. Weak hash maps are very much not effortless, and begin to consume (very) large amounts of time in garbage collecting, resizing, and reallocating the mapping table.

So I believe then the incentives are created for this proposal: both implementations shine, for different perf-related reasons, so long as they are used the way they are designed to perform well.

In practice this lets us efficiently do "parse don't validate" style coding with classes. Previously there was no mechanism for creating verifiable integrity that scaled to millions of object instances and beyond.

let integritySet = new WeakSet([], { properties: true });

export class ClassWithIntegrity {
  constructor() {
    // ...
  }

  logOK() {
    if (!integritySet.has(this)) throw new TypeError();
    console.log(this.value); // logs OK
  }
}

Here's another example of how you can use this to create data integrity, even when you're working with mutable data.

let fooData = new WeakMap({ properties: true });
let barData = new WeakMap({ properties: true });

class DataAccessors {
  constructor() {
    fooData.set(this, 'foo');
    barData.set(this, 'bar');
  }
  
  get foo() { return fooData.get(this); }
  set foo(value) {
    if (!value.startsWith('foo')) throw new Error();
    return fooData.set(this, value);
  }

  get bar() { return barData.get(this); }
  set bar(value) { return barData.set(this, value); }
}

Once again you could do this with the kind of WeakMap we already have, but once again you'd be hitting the perf use case that suffers most: implementing what should be a fast monomorphic struct access operation with slow hash map code and its internal operations like rebucketing that sometimes stop the world.

A final thought: the integrity use case, to function correctly, requires that the secret symbol used as the property key remain a secret. Without an engine-level implementation of propertyDescriptor.weak, it would be difficult to keep the symbol a secret. Luckily if the engine does not natively support properties: true, it can and will simply fall back to the map-based implementation, which will again provide integrity to the extent that the engine offers it. This time though the fallback has very broad support, so basically you could have runtime-backed integrity across almost any runtime, with perf that improves rapidly as runtimes implement properties: true.

Hmm I think the idea still isn't complete. What if one of the weak maps itself gets garbage collected? That's the real problem. Then to get back to the state you started with you'd have to go alter the prototypes of a bunch of live objects. That would not be good. That would be very slow.

If you just let the map be collected though and leave the weak properties, I don't really see a problem with that though, so long as it's made very clear that that's the behavior

Now that I think carefully about it, I think it's possible to polyfill this. The main thing is hiding the descriptors, which can be done by patching the implementations of getOwnPropertyDescriptors() and getOwnPropertySymbols. I would patch them to omit any symbol-keyed descriptor where symbol's description is '$__WEAK_KEY__'. Then the only way to look up the data would be by knowing the symbol.

… or by grabbing one of those functions (assuming you also included Reflect.ownKeys) from another realm, which you can't patch.

Yeah, I see what you're saying. So the polyfill wouldn't be bulletproof as security, but once engines implement it there would be no way to use this other-realm hack to evade the intent.

The polyfill wouldn't be able to define the property on a non-extensible object.

I'm fine with it if the real implementation shares that behavior from the polyfill. It would indeed mean that freezing an object would prevent its weakmapped value(s) from changing further.

It would mean that the two WeakMap implementations aren't completely identical in API though. You could now feature detect one from the other by testing the behavior on frozen objects.

Also while I see the same transitional security concern regarding iframes that @ljharb sees, I see this change as a part of a complete vision that also eliminates the current DOM API and replaces it with a fully declarative DOM API. As long as those changes arrive together (and I see no reason that they should not) my understanding is that no insecurity would exist: the technique of using another realm's Object implementation requires access to the (old/current) DOM API. The new API does not replicate the same flaw.

I'm considering breaking changes because I'm laying the foundations for an operating system. If I should succeed in building an environment that other people find it to be worth writing code in, it won't be about what old code runs in the new environment, but rather about what the new environment makes possible that no past one did. In that context it's obvious why I want class integrity. In fact it's critical for the implementation of the new DOM API that such strong integrity exists: the new immutable API design is predicated on it.

...and then immutability is the key to being able to design a DOM API which can safely sync DOM changes between threads in a desktop application architecture where you have many application threads, each of which also own a subtree of the OS-level DOM.

You can implement this with Private Fields and return override trick:

class Base {
  constructor(obj) {
    return obj;
  }
}

class WeakMap<K, V> {
  // Inner holds the private field, ensuring every new WeakMap instance gets
  // its own #key.
  #Inner = class extends Base {
    #key;

    static get(object: K): V | undefined {
      this.stamp(object);
      return object.#key;
    }

    static set(object: K, value: V): void {
      this.stamp(object);
      object.#key = value;
    }

    static stamp(object: K): void {
      if (#key in object) return;
      new this(object);
    }
  }

  get(object: K): V | undefined {
    return this.#Inner.get(object);
  }

  set(object: K, value: V): void {
    this.#Inner.set(object, value);
  }
}
1 Like

That's awfully clever! ...which makes me uncomfortable... ...but also it would seem to be able to provide the needed mechanism without the defects that my proposed polyfill would involve.

I like the idea that the complicated return-override-with-private-property machinery is encapsulated by a simple WeakMap-like API.

Please do not rely on this trick if you expect all objects to be usable as keys. The DOM already has affordances to disallow any object it wants (I believe only window currently), and with GitHub - tc39/proposal-nonextensible-applies-to-private: Proposal to disallow adding new private fields to non-extensible objects Β· GitHub at stage 3, expect non-extensible objects to also be disallowed soon in most engines.

There is actually a 3rd "hybrid" way, which is used by engines like Moddable XS: a hashmap to a "joiner slot", which is also linked from the object's "slot list". It avoids the ephemeron complexity of the normal hashmap approach.

In JS performance optimizations rarely justify a change in API, especially exposing imperative knobs to the author, because more often than not, the author will make the wrong assumptions.

Instead JS provides alternative constructs which imply different semantics. That's actually the case here with your examples: they all seem to assume the object with hidden data is this and you already rely on class syntax.

If that's the case, why don't you use normal private fields in your class definition? Do you actually need the WeakMap semantics of attaching private data to objects you didn't create?

Ah yes. What I'm not showing you is that in my real code I'm not using classes, I'm writing in the functional style using null-prototype objects and arrays as my core data types. Most instances are { type, value }.

As I say I'm not looking to use this to extend arbitrary objects, only to put an integrity seal on certain kinds.

You're suggesting, I guess, that I do it like this:

class ClassWithIntegrity {
  #__proto__ = Object.getPrototypeOf(this);
}

I'm asking... why! This just seems crazy to use the language's own prototype feature and then have to reinvent it on top of itself.

It would be better if you showed example like that. If you're avoiding classes but creating your own objects, it's possible private declarations would be sufficient for you? In particular the "Non-class object factories" example at the end.

Will you please share a concrete example of how you would write class code with integrity in your preferred idiom? By not sharing code you're avoiding showing me how you would handle crucial real details.

I thought this was pretty normal branding with class private fields:

class ClassWithIntegrity {
  #brand;

  constructor() {
    this.value = 'OK';
    Object.freeze(this);
  }

  logOK() {
    if (!(#brand in this)) throw new TypeError();
    console.log(this.value); // logs OK
  }
}

or probably more simply:

class ClassWithIntegrity {
  #value;

  constructor() {
    this.#value = 'OK';
    Object.freeze(this);
  }

  logOK() {
    console.log(this.#value); // logs OK
  }
}

or for the accessor example:

class DataAccessors {
  #foo;
  #bar;

  constructor() {
    this.#foo = 'foo';
    this.#bar = 'bar';
    Object.freeze(this); // added this to show freezing doesn't affect private fields
  }
  
  get foo() { return this.#foo; }
  set foo(value) {
    this.#foo = value;
  }

  get bar() { return this.#bar; }
  set bar(value) { this.#bar = value; }
}

And for private declarations:

private #foo;
const makeObj = (foo) => Object.freeze({
  __proto__: null,
  outer #foo: foo,
});