Proposal Records (a new one)

I've evolved my ideas a bit beyond where they were with Object.freezeRecord, although it might still be nice to have here.

Here's the highlights:

let { freeze, setPrototypeOf } = Object;

// Same syntax vetted by the O.G. proposal
let record = #{
  foo: 'bar',
  baz: #['quux', 'quuux']
};

typeof record; // 'object'
Object.getPrototypeOf(record); // null
Object.isFrozen(record); // true

// We can construct a record using existing language features
let r = (value) => freeze(setPrototypeOf(value, null));

// equivalent to the first record but not == to it (no change)
let record2 = r({
  foo: "bar",
  baz: r(["quux", "quux"]),
});

// JSON deserializes nicely to records
let record3 = JSON.parseRecord('{ "foo": "bar", "baz": ["quux", "quuux"] }');

An object or array is considered to be a record if it is a frozen, has no-prototype, no getters, and all of its contained values are either records or primitives.

Object.isRecord(record); // true
Object.isRecord(record2); // true
Object.isRecord(record3); // true
Object.isRecord(new Proxy(record, {})); // true

Object.isRecord(null); // false
Object.isRecord({}); // false
Object.isRecord(freeze({})); // false
Object.isRecord(r({ foo() {} })); // false
Object.isRecord(r({ get foo() {} })); // false

Here's the whole proposal so far. I guess I'm going to have to learn to write ecmarkup...

This would also open the door to an API like Object.recordsEqual(a, b)

Fwiw, a requirement I would insist on for any equality predicate is that it be stable. That means that for the same pair of objects if it ever returns true/false it must continue returning true/false. The consequence of that is that if any aspect of the object under comparison is mutable the predicate must not give a result (e.g. throw).

Regarding the proposal itself, I believe this approach was considered. One of its problem is that it doesn't mesh well with existing collections (record objects would keep being compared by their object identity).

1 Like

Fwiw, a requirement I would insist on for any equality predicate is that it be stable. That means that for the same pair of objects if it ever returns true/false it must continue returning true/false. The consequence of that is that if any aspect of the object under comparison is mutable the predicate must not give a result (e.g. throw).

I'm fine with that for recordsEqual. I think it's implied from the name that to be equal the things must be records.

The langauge itself strongly, strongly implies that no-proto data is supported. I've had people on this very forum tell me that I should use no-proto data structures!

It's incredibly obvious why you'd want to be able to use them. The committee put them into the language, thus incurring all the interop risk and new complexity that you are worrying about: all that is already the norm. The cost is paid. The complexity is in the language. The situation we're in now is that we have all the complexity and none of the benefit.

Hi @conartist6!

As per your note from the other thread

Naturally some overlap with GitHub - tc39/proposal-composites Β· GitHub, which I am currently championing.

Here are some of the presentations I've given to the committee around this topic in the last few years:

The core observation is that when you have an immutable and inert (no side effects when reading) data structure this is ideal for the equality needed for Map and Set keys - they can guarantee being consistent, reflexive, symmetric, transitive.

This leads to a design cycle, when trying to create a proposal for composite map keys you end up with something like an immutable and inert record. And when you design an immutable and inert record you get close to something that would be a great candidate for composite map keys.

The exact solution for how the equality should work is still unclear with some open questions that I am working to resolve. But these questions do impact the design of the records themselves so does not fit well as a follow-on proposal. This is why I'm approaching the proposal from the equality perspective as the primary goal, as immutably falls out from that anyway.

One note is that both the Composite and Records&Tuples proposals were adding something more like a constructor/factory rather than a "transform". An object can't become a composite after it is created. Doing Composite(obj) creates a new object based on the properties of the obj passed in. It is not like Object.freeze. This is primarily because of the equality aspect - for equality to be consistent the mode of equality has to be fixed from creation time.

When you say a data structure is inert you're talking about the potential to have a proxy wrapper right? I can surely see why you'd want a structure used as a map key to be inert in that way.

I'm still trying to figure out which proposal is more elemental: composites or records.

You seem to be suggesting that you now have changed your stance and feel that composites are a more primitive kind of thing than records, but at the moment I'm leaning the other way. I think records as a data type are already essentially in the language -- fully defined, and it has to be possible to use them as map keys without triggering compositing behavior. That would suggest to me that records would be the feature to build composites on top of rather than the other way around.

But indeed I also see clearly from what you say that records don't eliminate the need for composites. Some way is needed to tell a map to treat a particular record as having structural equality for the purposes of key lookup, but that needs to be a novel kind of object because it's a novel new functionality of the language.

So I guess what I'm saying is, why not Composite(freezeRecord({}))? This would allow Composite to be a novel kind of object while a record was a plain one. The Composite would be novel in that it would impose an additional requirement of inertness (not possible to impose without some new engine-level support) and would guarantee a consistent identity from birth.

Another way of saying this might be "composites offer a higher level of data integrity than records". Actually it may be less of a different level and more of a different kind, though. Not all Composites need be records.

What the Composites proposal doesn't provide for is null-prototype data structures, which I still think are key to offer because they're how the language can make . property access safe from prototype pollution.

(also, Hi!)

I'm trying this out to see if it works and if I can propose a specific polyfill.

These structures have no prototypes they have no iteration behavior defined, which of course is exactly what we want. We still don't want arbitrary behavior mixed into our data, we want to use our trusted code interact directly with the data structure. This lets us do that nicely:

function* recordEntries(obj) {
  if (!isRecord(obj)) throw new Error();

  if (isArray(obj)) {
    let { length } = obj;
    // only indexes get entries, not obj.length
    for (let i = 0; i < length; i++) yield [i, obj[i]];
  } else {
    // records don't need hasOwnProperty
    for (let key in obj) yield [key, obj[key]];
  }
}

/* recordKeys and recordValues are similar */

Object.recordKeys = recordKeys;
Object.recordValues = recordValues;
Object.recordEntries = recordEntries;

let arrayRecord = #[3, 2, 1];
let record = #{
  foo: 'Foo',
  bar: 'Bar'
};

[...recordKeys(record)]; // ['foo', 'bar']
[...recordKeys(arrayRecord)]; // ['0', '1', '2']

I'm having another brainwave which is that we can have both deep and shallow validation, since this is an emergent definition of what is a record that really stems from what's already in the language.

For example in my project I have code like this:

import BarGrammar from './bar.js';

class FooGrammar() {
  static dependencies = { BarGrammar };

  Production(p) {
    p.eat('ok');
  }
}

export default FooGrammar;

I definitely want FooGrammar.dependencies to be a record-like object -- it shouldn't have a prototype, and I want validate that it doesn't have any getter properties. It wouldn't meet the full definition of a deep record though -- a deep record requires that all nested values are primitives or records, and that just isn't true here: the nested BarGrammar is a class with methods, just like FooGrammar is.

So the spec could split the methods up like this:

let { freezeRecord, isRecord } = Object;

let shallowRecord = freezeRecord({ value: new URL() });
isRecord(shallowRecord) // true

let { deepFreezeRecord, isDeepRecord } = Object;

let deepRecord = freezeRecord({ value: freezeRecord({}) });
isDeepRecord(deepRecord) // true
isDeepRecord(shallowRecord) // false

Then recordEntries would go from using a deep isRecord check to using a shallow one, which would also be fine. Code that needs deep protection would use the deep check, which would be the only one that would get you the full benefits like being able to move data across realm boundaries

I've posted a fully-tested polyfill on the official proposal repo that anyone can try out if they wish:

curious to learn what's the point of a "polyfill" when it's syntax changes we're after here ... is the polyfill meant to land on Babel or simiar tools?

Syntax has to map to some kind of semantics. These are the semantics that I think make sense to be paired with the record syntax, but the semantics exist even if the syntax doesn't.

let record = #{
  outer: #{ inner: 'ok' },
};

// no new syntax needed for the proposed semantics
let polyfillRecord =
  Object.deepFreezeRecord({ outer: { inner: 'ok' } });

Object.isDeepRecord(record); // true
Object.isDeepRecord(polyfillRecord); // true

Object.getPrototypeOf(record); // null;
Object.getPrototypeOf(polyfillRecord); // null;

// no implicit structural equality
record === polyfillRecord; // false

// explicit structural equality can be safe and good though!
recordsDeepEqual(record, polyfillRecord); // true

The point is that if these record semantics were chosen then basically you could start using forward-compatible records right away without even needing to wait for the babel parser to be changed.

When language level support for isDeepRecord was added, this "polyfill" version of the code would defer to the native version and it would run faster.

I get that ... thing is, freeze makes objects slower while the whole premise or records is to have, hopefully a close to C/C++ structs based performance for well defined shapes, in a way that no turbo-fan/JIT needs to guess optimizations, those are right there already thanks to the new syntax.

This polyfill feels like a huge overhead that is working against that initial goal, hence my questions around the purpose ... I hope transpilers will try to ship the most meaningful way to have Records without choking on details, that's it, thanks for the follow up.

It's been since Mozilla proposed CTypes that I am looking forward to this new entry in the JS field, among the need to have SharedArrayBuffer delivered after the Spectre/Meltdown paranoia in a way that is always enabled just like WASM SAB is ... hence my questions/concerns around polyfills that likely will never fulfill the bill for the feature we're actually after.

I don't think that freeze really needs to make anything slower, does it? As far as I know monomorphic access to frozen objects is not any slower than monomorphic access to non-frozen objects.

The two things I know are:

  1. Having a syntax will enable additional optimizations that are not possible without a syntax. These syntax-enabled optimizations can be achieved without introducing new semantics.
  2. The only thing that can really make both the syntactic and semantic property access faster is not having to traverse the prototype chain. This is the only active proposal (I know of) that achieves this benefit.

I like the approach (null prototype, frozen, not a new data type), but:

  • 'record' sounds more like a data type than a quality of the data, and is strongly associated with DB's. I would recommend an adjective like 'lean' (to convey the lack of prototype methods); I think on first glance a person could deduce what lean probably does, like freeze/sealed.
  • Recursive built-in methods already exist (JSON.parse, etc) and already throw when encountering problems. I see no reason why you can't have an Object.lean(). This would be ergonomic when working with API data and such (like your parseRecord idea).
  • the prior record/tuple proposal cared a lot about equality of records, I'm not seeing much of that here? I had some back-forth with aclaymore about that specific problem, leading me to write a wider lateral proposal: GitHub - jason-ford-x/proposal-equality: A global Equality API object that exposes explicit methods for current and new equality algorithms Β· GitHub
  • talk of immutable data structures also brings up the desire for lots of other ergonomics, like pattern matching/validating, smart factories, value validation, structure transformers, etc. Consolidating all of those desires under a new data type is attractive due to the necessary wiring. It feels like such a thing could be a superset of this proposal (structure-as-identity, safer property accessing).

Personally I really like before/after code examples showing where it makes a big difference, I find those are easier to connect with and be inspired.

1 Like

The fact that I'm not proposing any new semantics is both a blessing and a curse.

The curse: I have no control over equality semantics. They are what they are.

The blessing: It's harder to block the proposal. Since the proposed semantics are all already in the language, any new proposals have to take them into account anyway (at least in the basic sense).

As for lean() as a name, I specifically wanted a name with a verb in it. You could go for something like leanify() or makeLean() to distinguish from isLean(), but actually the database association is exactly why I thought term "record" made some amount of sense: a database record is usually understood to be "just the data". Any algorithms would be kept in a stored procedure or a view table or something that is fundamentally an extra layer on top of the plain rows/records.

If i'm understanding correctly:

  • equality is not a priority
  • main problem you're trying to solve is Object[whatever] has risks/confusion

If there was an explicit of-own accessor syntax, like : or something, I feel like that would address some portion of your proposal and also some other general JS pain-points. A larger broader quality-of-life enhancement is more likely to get implemented, such as nullish coalescence (??) for example.

I would like to see some examples where being a record makes a big difference, either by avoiding a common error and/or providing some convenience/peace-of-mind that did not previously exist. When I did this exercise for my proposals, it made me rethink a lot of things.

You're correct that an explicit access-own syntax like foo:bar could solve part of the problem. As part of my design process I considered a solution like that at some length, so I'm happy to discuss the details of it!

To have parity with what this proposal suggests, that foo:bar would also need to forbid getter invocation. If you did that you'd at least have the security-hole side fairly well covered.

What a new record accessor syntax wouldn't do is create a clear notion of deep equality. This proposal at least makes it clear how you would implement recordsDeepEqual so that it could be implemented in library-space. A proper composites proposal could still follow on, creating a mechanism by which Map and Set could trigger structural equality behavior instead of reference equality behavior.

As for where it makes a big difference, in a word, validation.

let validNodes = new WeakMap();

let nodeFactory = (childNodes) => {
  if (!Object.isDeepRecord(childNodes)) throw new Error();
  for (let i = 0; i < childNodes.length; i++) {
    if (!validNodes.has(childNodes[i])) throw new Error();
  }

  let node = Object.freezeRecord({ childNodes });

  // We can only cache validity for deep immutable structures
  validNodes.add(node);

  return node;
};

Finally I don't contribute to TC39 just out of the goodness of my heart. I'm giving so much time to honing this proposal because I need it urgently. With a new syntax I'd have to either wait years or change every foo.bar to recordGetAt(foo, 'bar'). Neither of those options is attractive to me.