Object.freezeRecord

This is part of a continuing attempt to figure out how to handle immutable data in Javascript.

My goal is to create some kind of deep validation function that can be used at a function boundary so that inside the function, property access like validatedObj.foo or validatedObj[0] are known to be safe, or as safe as possible in a world with proxies.

This idea supplants my own earlier Object.deepFreeze idea:

// Only null-proto objects can become records
Object.freezeRecord({}); // throws a TypeError

Object.freezeRecord(Object.create(null)); // OK

Using records looks like this:

function doubleValue(obj) => {
  if (!Object.isRecord(obj)); throw new Error();

  return obj.value + obj.value;
}

To keep things dirt simple for the implementation, freezeRecord only ever freezes one object at a time. It's not deeply freezing, but it is doing deep validation:

let { assign, create: createObject, freezeRecord } = Object;

let plainObj = obj => assign(createObject(null), obj);

let record = freezeRecord(plainObj({}));

// records nest within records
freezeRecord(plainObj({ foo: record })); // OK

// no non-record, non-primitve values
freezeRecord(plainObj({ foo: createObject(null) })); // TypeError

// no getters
freezeRecord(plainObj({ get foo() {} })); // throws a TypeError

It would be absolutely critical to pair this with some performant engine-supported way of making null-prototype arrays. I would recommend the API be Object.createArray(null). Without createArray (or something like it) this whole thing is a pipe-dream from the perf standpoint. Right now you have to use the deathly-slow setPrototypeOf to make an array with a null prototype.

The deep guarantee of prototypeless-ness ensures that all internal uses of . property access are free from prototype pollution. The guarantee against getters and un-frozen properties means the data in the record is immutable. Our doubleValue function should always return the correct result!

In terms of how well it would integrate with other language features, I think it's pretty tame. There might be some overlap with proposal-composites, but even out of the box they'd play nicely enough together. Records could probably be zero-copy transferred using structuredClone too which'd be great.

@aclaymore Both the most closely related proposals, proposal-record-tuple and proposal-composites, are yours. I guess that means I'm especially interested in your feedback!

Eventually I think you'd also take the proposed record syntax from proposal-record-tuple and make it sugar for calls to freezeRecord so that you could just write this:

function doubleValue(obj) => {
  if (!Object.isRecord(obj)); throw new Error();

  return obj.value + obj.value;
}

doubleValue(#{ value: 2 }); // 4

If I understand, the benefit this has over the previous records proposal is that it's much more well-defined how records interact with other types. The records proposal was tricky in that way because it made typeof record === 'record' which would have caused existing object-based metaprogramming to go haywire

The biggest practical difficulty this proposal faces is the impracticality of making null-prototype arrays in any current runtime. For what it costs to use setPrototypeOf you may as well not be able to do it at all, which would mean that while this whole proposal can be polyfilled, it cannot be polyfilled with acceptable performance as it has been stated here : (

I see two potential ways to go about this:

  1. Rush towards support for a "low-level" method like Object.createArray(null), and lean on the poor-performing polyfill where we must. We know this can be done because it has been done in the past with primitives like Map and WeakMap which started out with poor-performance polyfills (and indeed experienced very slow initial adoption)
  2. Allow records to be plain objects (with Object.prototype or Array.prototype) and introduce a #. "own property access" operator. Such an operator could be used with any object and would never access properties from the prototype chain.

I think solution 1 is the better design. It's closer to the ideal. JS authors don't have to remember to use #. to stay safe when working with records. Use records and you can't get hurt. Instead of another nitpick thing to remember to do stay safe, coders get an actual strong shield that protects everything behind it.

The problem with solution 1 is that it's a hardish-break in compatibility because of the poor-performing polyfill. If I go out and write a Figma clone that uses Records, it isn't going to run well on any runtime that doesn't have native support for Object.createArray(null), which is every current runtime. So basically it could be... years... before devs could really architect around this feature and expect it to run well for users everywhere.

By comparison polyfilling record#.property is dirt cheap because there's no setPrototypeOf needed. You can just desugar to an intermediate step with a hasOwn check. The #. would tend to proliferate in data-handling code, but it's useful even if you aren't using records and I would think that it would allow the runtime to implement the operation with better performance too: checking the prototype chain isn't free.