Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

2025 February TC39 presentation update #393

Open
acutmore opened this issue Feb 24, 2025 · 62 comments
Open

2025 February TC39 presentation update #393

acutmore opened this issue Feb 24, 2025 · 62 comments

Comments

@acutmore
Copy link
Collaborator

At TC39 last week (2025/02) we discussed R&T.
Slides here: https://docs.google.com/presentation/d/1uONn7T91lfZDV4frCsxpwd1QB_pU3P7F6V2j9jEPnA8/.
Minutes are typically available three weeks after plenary.


After the feedback received from committee in 2023 the proposal has been looking for a new design that:

  • Does not add new primitives (no new typeof)
  • Does not overload ===
  • Still adds immutable data structures
  • Still adds nested/composite equality operations

After discussing some designs with various delegates last week. I currently have:

// Creation:
const recordLike = Composite({ x: 1, [Symbol()]: 2 });
const tupleLike = Composite([1, 2, 3]);
typeof recordLike; // "object"
try { new Composite([]) } catch { /* throws */ }

Object.isFrozen(recordLike); // true
Object.isFrozen(tupleLike);  // true
Array.isArray(tupleLike);    // true

Object.getPrototypeOf(recordLike); // `Object.prototype`
Object.getPrototypeOf(tupleLike);  // `Array.prototype`
recordLike instanceof Composite;   // false

Composite.fromEntries(Object.entries(someObj));
Composite.fromIterable(someIterable);
Composite.of(1, 2, 3);

// Syntax (sugar)
#{ x: 1, y: 2 }; // Composite({ x: 1, y: 2 });
#[1, 2, 3];      // Composite([1, 2, 3]);
// Generic containers
const mutableObj = new Set();
const t = #[];
const c = #{ o: mutableObject, t, z: -0 };
c.o === mutableObject; // true
c.t === t;             // true
Object.is(c.z, -0);    // true
// Predicates
Composite.isComposite(#{}); // true
Composite.isComposite(0);   // false

#{} !== #{};         // true
#{} != #{};          // true
Object.is(#[], #[]); // false

const obj = {};
Composite.equal(#[obj], #[obj]); // true

Composite.equal(#{ x: #[0, NaN] }, #{ x: #[-0, NaN] }); // true

const a = "a", z = "z";
Composite.equal(#{ a, z}, #{ z, a });  // true

Composite.equal(1, 1); // true

Composite.equal([obj], [obj]);        // false
Composite.equal(#{ length: 0 }, #[]); // false
// Language integration
new Set([#[], #[]]).size; // 1

let m = new Map();
m.set(#[], true);
m.has(#[]); // true

Map.groupBy(iter, v => #[v.x, v.y]);

Composite.isComposite(#[1, 2, 3].filter(v => v > 1)); // true

[#[]].includes(#[]); // true
[#[]].indexOf(#[]);  // 0

// There are various options on how to handle composites in weak positions.
// For now: reject
try { new WeakSet([#[]]) } catch { /* throws */ }
@acutmore acutmore mentioned this issue Feb 24, 2025
25 tasks
@slorber
Copy link

slorber commented Feb 24, 2025

Thanks for the update!

const obj = {};

Composite.equal(#[obj], #[obj]); // true

Composite.equal(#{ x: #[0, NaN] }, #{ x: #[-0, NaN] }); // true

This looks like a quite interesting behavior.

Is this expected to perform faster than deepEqual(obj1,obj2)?

Although I would have hoped for === to work, I guess this could be a good enough equivalent. A framework like React could use this for useEffect dependency array or when comparing props in React.memo.

Are there other integrations considered/planned? For example, reading API responses as composites, or converting a deeply nested object to a composite? Is JSON.parseImmutable() still on the table?

How does this get represented in TypeScript? Can we encode in the type system that something is a Composite/Record/Tuple?

I'm curious to know more about the possible WeakSet options. Depending on the behavior, this could probably be useful to implement composite interning in userland.

@littledan
Copy link
Member

Composite.equal is expected to be linear time, like any deepEqual function.

@slorber
Copy link

slorber commented Feb 24, 2025

Composite.equal is expected to be linear time, like any deepEqual function.

I see thanks.

When using map.get(), set.has() and other operators with large maps/sets, can't this be a performance problem? Or is there a way to index those composites for fast lookup?

@nicolo-ribaudo
Copy link
Member

When using map.get(), set.has() and other operators with large maps/sets, can't this be a performance problem? Or is there a way to index those composites for fast lookup?

The way maps/sets work is that you need to:

  1. compute the hash of the key
  2. lookup the hash in the map
  3. check that the key indeed matches

1 and 3 are O(size of the key), and 2 is O(1). So maps/sets using composite key would still be constant time with respect to the number of elements in the map.

@rauschma
Copy link

rauschma commented Feb 24, 2025

This looks great!

A few ideas:

  • I’d prefer to have separate factory functions:
    const recordLike = Record({ x: 1, [Symbol()]: 2 });
    const tupleLike = Tuple([1, 2, 3]);
    
    // Predicates
    Record.isRecord(#{}); // true
    Record.isRecord(#[]); // false
    Record.isRecord(0); // false
  • Loosely related to the previous point – I think having two iterator methods instead of a single .toComposite() makes sense – e.g. when you have an iterable over pairs or an empty iterable:
    Iterator.prototype.toTuple()
    Iterator.prototype.toRecord()
    
  • Could Composite.equal() be Object.equal() and work for any values?
    • Downside: It would be different from the operation used by .indexOf(), .includes(), etc. – which then should also be exposed in some manner.
    • Upside: It’d be really useful to be able to compare arbitrary, potentially nested, values and the algorithm would mostly be the same(?)
  • That is interesting and very useful:
    // Language integration
    new Set([#[], #[]]).size; // 1
    
    let m = new Map();
    m.set(#[], true);
    m.has(#[]); // true
  • Could this lay the foundation for classes that produce immutable objects that are compared by value in collections? That also seems useful.

@demurgos
Copy link

demurgos commented Feb 24, 2025

Thank you for the update. I'm happy that it's moving forward, but I'm still disappointed that it requires custom integrations and comparisons instead of relying on existing mechanisms. (===, indexOf, etc.). Integration with sets and maps is probably the most important use case, and Composite.equal can become the new "universal equivalence check".

const obj = {};
Composite.equal(#[obj], #[obj]); // true

Is this right? Regular objects would be allowed inside composites?

Composite.equal is expected to be linear time, like any deepEqual function.

One reason for native support of immutable structs was better integration with the engine to support stuff like interning or a cached hash. The worst case would still be linear obviously, but I assume that there may be faster paths eventually for certain patterns.

@ljharb
Copy link
Member

ljharb commented Feb 24, 2025

Personally I like composite keys quite a lot, but I don't think syntax is worth it without ===, which was the vast majority of the benefit of the R&T proposal imo.

@acutmore
Copy link
Collaborator Author

but I assume that there may be faster paths eventually for certain patterns.

Fast paths are theoretically possible, when the values are not equal and if their hash values have already been computed and cached then the equality check may bail out immediately. The existence of such fast paths would not be part of the spec and should not be relied upon, so the equality check should always be thought of as being O(n). This is somewhat similar to how strings work today, though their data structure is simpler so not exactly the same.

One advantage of composites is that they are guaranteed to not trigger user code (not proxies, no getters) which at least means that these aspects don't need to be guarded against. So what can be relied upon is that when given two values the check will always return the same result, and will never throw an exception.

@acutmore
Copy link
Collaborator Author

Is this right? Regular objects would be allowed inside composites?

That is correct. A composite would not be a guarantee of deep immutability. At this point in JS's life I think the ship has sailed, essentially every object in JS is mutable either internally or publicly. Even newer Proposals such as Temporal are only internally immutable, they are still extensible.

It's possible to imagine an additional API Composite.isDeep, though this may also be a linear check unless engines found space to store an extra bit of information on every composite.

@mindplay-dk
Copy link

Since composites are objects, and not instanceof Composite (and since the function works on objects and not only on composites) shouldn't it be Object.isComposite rather than Composite.isComposite?

@acutmore acutmore pinned this issue Feb 24, 2025
@acutmore
Copy link
Collaborator Author

Is JSON.parseImmutable() still on the table?

Personally I would still like it to happen, similar to how it is now it would be a follow on piece of work to investigate.

How does this get represented in TypeScript? Can we encode in the type system that something is a Composite/Record/Tuple?

It would need a new marker from TypeScript to encode as structurally they are no different from regular objects/arrays. Maybe it would be composite & { readonly x: 1 }. I'll chat with the TS team to get their thoughts.

I'm curious to know more about the possible WeakSet options

  1. Always reject composites
    • simple, yet possibly surprising
  2. Always accept composites (A)
    • they are keyed by referential equality (match the way objects work today)
  3. Always accept composites (B)
    • they are keyed by Composite.equal
      • composites with no objects and no symbols* would effectively 'leak' when put in a WeakMap
      • composites with some objects/symbols* would only be removed when one of those objects/symbols is collected - otherwise they 'leak'
  4. Sometimes accept composites
    • they are keyed by Composite.equal
    • they are only accepted if they contain at least one lifetime bearing value non-composite-object/non-registered-symbol
    • composites with some objects/symbols* would only be removed when one of those objects/symbols is collected - otherwise they 'leak'

Note: 3 and 4 can be done in userland but 2 can't. 2 would break the similarity between Map and WeakMap.

shouldn't it be Object.isComposite rather than Composite.isComposite?

Potentially. Technically all of the APIs could be on Object and there is no new Composite namespace. Putting everything on Object makes it quite crowded IMO, it felt better to have a new namespace. I'm sure the location and name of the APIs will be discussed at length.

@HappyStriker
Copy link

I really hope it is still time to reconsider to not drop the idea of using ===, which would be awesome and elegant.
Also like @rauschma suggested, the dedicated Record and Tuple factory functions are more straightforward in my opinion.
What I do not get, please help me understand this, is why instanceof shall not work. The records, tuples, or even composites could just be instances of their own with Object in their prototype chain, right?

@demurgos
Copy link

Also like @rauschma suggested, the dedicated Record and Tuple factory functions are more straightforward in my opinion.

I'm eagerly waiting for the minutes to see what was discussed; however having a single constructor is consistent with dropping support for ===, and it should be considered as a whole. Except equal, all methods on Composite are used for construction or as "instanceof" replacement, so they could live on separate constructor/namespaces. For the equality check though, the slides already call out that it's a 5th equality. Splitting into Record.equal and Tuple.equal would be pure inconvenience. You could also keep the split Record/Tuple for constructors and instance checks, with equality somewhere else (e.g. Object.compositeEqual) but it feels like a worse solution. Compared to either:

  • Split constructor, and equivalence check using ===
  • Merged constructor, and equivalence check using a single namespaced method

Is this right? Regular objects would be allowed inside composites?

That is correct. A composite would not be a guarantee of deep immutability. At this point in JS's life I think the ship has sailed, essentially every object in JS is mutable either internally or publicly.

Thanks for the confirmation. Shallow immutability can easily be checked recursively in user-code to enforce it deeply. In particular, a recursive check still allows to get rid of defensive copies which was one of the goals of the proposal. It's cheaper to check once when the value is received rather than copying on each access. 👍

The existence of such fast paths would not be part of the spec and should not be relied upon, so the equality check should always be thought of as being O(n). This is somewhat similar to how strings work today, though their data structure is simpler so not exactly the same.

I understand that it's not exactly the same, but the way strings are handled today is proof to me that the perf discussion (and === support) is not so black and white. I agree however that the spec should not require any guarantees from implementations, and O(n) should be assumed.

@Maxdamantus
Copy link

Maxdamantus commented Feb 25, 2025

// Language integration
new Set([#[], #[]]).size; // 1

let m = new Map();
m.set(#[], true);
m.has(#[]); // true

Map.groupBy(iter, v => #[v.x, v.y]);

Composite.isComposite(#[1, 2, 3].filter(v => v > 1)); // true

[#[]].includes(#[]); // true
[#[]].indexOf(#[]);  // 0

So just to clarify, does this mean every practically every current use of equality is being updated except the equality operators (==, ===, !=, !==) and Object.is? That is, as if SameValueZero is being updated to treat different composite objects as equal, and === would use a new definition of equality that distinguishes them?

Same as some others here, I'm also very much in favour of using === for R/T/composite values (so as with strings, repeated evaluations of #[] would produce indistinguishable values, even if the engine uses different underlying memory allocations).

Disregarding the ergonomic advantages of R/T ===, wouldn't this make an R/T polyfill a lot more intrusive, since it would need to replace every constructor/function that uses equality? With R/T ===, the polyfill needs to maintain an inefficient trie of WeakMaps (and there are some issues around -0/+0 values), but the scope of the polyfill is at least minimal, and if R/T values are never used, there's practically no impact.

@mhofman
Copy link
Member

mhofman commented Feb 25, 2025

What I do not get, please help me understand this, is why instanceof shall not work.

Technically instanceof could be made to work by having Composite[@@hasInstance] simply alias Composite.isComposite

That is, as if SameValueZero is being updated to treat different composite objects as equal

I believe that's the hope.

I'm curious to know more about the possible WeakSet options

Note: 3 and 4 can be done in userland but 2 can't. 2 would break the similarity between Map and WeakMap.

For the record, I stated last week that 3/4 currently seem unacceptable to me since I expect it to break too much code that expect Weak collections to use the identity of the key. I do not however expect much code to rely on WeakMap and Map to have interchangeable keying semantics, and since you can implement 3/4 in userland with a trie of WeakMap, I'm ok with both 1 or 2.

@spartanatreyu
Copy link

spartanatreyu commented Feb 25, 2025

Not keeping Composite separate as Record and Tuple seems like it's going to create a bunch of boilerplate that I'll have to remember, which I'm not going to want to write for every project and just push me away from using them.

If I have a function that receives a "thing", and I want to do something different depending on if it's a record, tuple or something else, I would rather write:

// nice and neat

function measureThing(thing) {
    if (typeof thing === "string") {
        return ...
    }
    if (typeof thing === "record") {
        return ...
    }
    if (typeof thing === "tuple") {
        return ...
    }
    return ...
}

or even:

// slightly less neat

function measureThing(thing) {
    if (typeof thing === "string") {
        return ...
    }
    if (Record.isRecord(thing)) {
        return ...
    }
    if (Tuple.isTuple(thing)) {
        return ...
    }
    return ...
}

rather than:

// painful

function measureThing(thing) {
    if (typeof thing === "string") {
        return ...
    }
    if (Composite.isComposite(thing)) {
        if (Array.isArray(thing)) {
            return ...
        }
        return ...
    }
    return ...
}

My first code example is really easy to read and write, plus it could be easily refactored into something like this when pattern matching becomes available:

// heaven

function measureThing(thing) {
    return match(typeof thing) {
        when "string": ...;
        when "record": ...;
        when "tuple": ...;
        default: ...;
    }
}

@Maxdamantus
Copy link

Maxdamantus commented Feb 25, 2025

What I do not get, please help me understand this, is why instanceof shall not work. The records, tuples, or even composites could just be instances of their own with Object in their prototype chain, right?

I suspect this isn't the rationale, but as @rauschma alluded to, I like the idea of having records with custom prototypes (assuming this would need to be a future proposal).

The linked slide deck has a hidden slide with:

#{ __proto__: vector2DProto, x, y }

(personally, I'd prefer just specifying the prototype as a second argument to Composite or Record, eg Composite({ x, y }, vector2DProto), since the ugly __proto__ stuff shouldn't be propagated further than Object.prototype, but this is a minor nit)

I can imagine if custom prototypes are used, there would also be some syntax like composite class Vector2D { ... }, and Composite.isComposite(Vector2D(4, 5)) would be the reliable test for composite values.

This can also be compared with other "reliable" ways of detecting certain types of values:

  • Array.isArray(a) rather than a instanceof Array, since array objects might have prototypes other than Array.prototype
  • typeof o === "object" rather than o instanceof Object, since objects might lack a prototype, or have a prototype that doesn't have Object.prototype in its chain
  • Error.isError(e) rather than e instanceof Error, since it works across realms (also applies to the other examples above)

Similar to Array.isArray and Error.isError, the Composite.isComposite pattern also correctly classifies objects that might be impersonating special types of values. eg, Object.create(Array.prototype) instanceof Array, but that's not actually an array object (it doesn't have the special behaviour around setting the .length property).

@acutmore
Copy link
Collaborator Author

acutmore commented Feb 25, 2025

Disregarding the ergonomic advantages of R/T ===, wouldn't this make an R/T polyfill a lot more intrusive

Yes, === can be 'polyfilled' without having to modify existing APIs. Whereas the de# this thread requires the polyfill to modify Array,Map,Set, and potentially Weak{Set,Map},WeakRef, and FinalizationRegistry. While this is good to note, I think that the complexity here is justified. The proposal has been researching a === based design for many years and has come to the conclusion that it is not a design that can move forwards.

if (typeof thing === "record") {
return ...
}
if (typeof thing === "tuple") {
return ...
}

This is actually the type of code that the proposal is trying to discourage. Most functions that can work on records/tuples can also operate on objects/arrays. Conversely there is already lots of code that currently only works for objects/arrays and would not work if passed something that had a different typeof. That said if code did find itself needing to switch on composite objects/arrays then it could write a small utility that would return a custom $typeof(v)utility to clean up the switch. And for the pattern matching proposal maybe custom matchers mean that it could look something like this:

function measureThing(thing) {
    return match(thing) {
        when String(str) -> ...,
        when Composite([...tupleLike]) -> ...,
        when Composite(recordLike) -> ...,
    }
}

@mAAdhaTTah
Copy link

Personally I like composite keys quite a lot, but I don't think syntax is worth it without ===, which was the vast majority of the benefit of the R&T proposal imo.

FWIW, I feel similar, and would note that if we were to drop the syntax from the proposal, that could free up the # for the pipeline operator's placeholder (related issue).

@spartanatreyu
Copy link

if (typeof thing === "record") {
return ...
}
if (typeof thing === "tuple") {
return ...
}

This is actually the type of code that the proposal is trying to discourage.

Why?

Discouraging that style of code seems like a problem to me.

I want an easy way to tell if a variable is a record, or a tuple (or something else).

Combining both record and tuple into Composite() does not help me with that issue.

I suggested both typeof and Record.isRecord() and Tuple.isTuple() to tell them apart but typeof is preferred.

We currently use typeof for all immutable primatives (e.g. string, number, symbols, etc...), and as the proposal states: You could think of Records and Tuples as "compound primitives"., and since records and tuples are already immutable it only makes sense to use typeof to tell them apart. And adding new returnable values of the typeof operator has happened before (i.e. bigint, symbol) so it's not out of the realm of possibility.

Conversely there is already lots of code that currently only works for objects/arrays and would not work if passed something that had a different typeof.

Everything that comes to my mind (for loops, searching methods, Set and Map constructors) should work similar to the iterators and instance methods of frozen objects/arrays. Can you give an example of where this would be an issue?

That said if code did find itself needing to switch on composite objects/arrays then it could write a small utility that would return a custom $typeof(v)utility to clean up the switch.

That is the exact issue I brought up. I don't want to have to write boilerplate code to tell records and tuples apart. I want it to be easy and ergonomic.

typeof does that for me.

Having to write code to distinguish Arrays from Objects, then Arrays from Frozen Arrays and Objects from Frozen Objects is the exact reason I'm not using Object.freeze() now. It's just too much hassle. I'd rather just annotate something as as const; in typescript, and design systems that keep my may-be-mutated and will-never-be-mutated data structures separated so I never have to differentiate between them.

If it's not easy and if it's not ergonomic, then I'm not going to use it.

@mindplay-dk
Copy link

Having to write code to distinguish Arrays from Objects, then Arrays from Frozen Arrays and Objects from Frozen Objects is the exact reason I'm not using Object.freeze() now. It's just too much hassle. I'd rather just annotate something as as const; in typescript, and design systems that keep my may-be-mutated and will-never-be-mutated data structures separated so I never have to differentiate between them.

If it's not easy and if it's not ergonomic, then I'm not going to use it.

this. 💯

I honestly don't know about typeof though - I mean, this obviously looks great on the surface:

// nice and neat

function measureThing(thing) {
    if (typeof thing === "string") {
        return ...
    }
    if (typeof thing === "record") {
        return ...
    }
    if (typeof thing === "tuple") {
        return ...
    }
    return ...
}

but what about code that's already testing broadly for typeof thing === "object"?

if typeof is changed and begins to return more specific types, code that is trying to determine the fundamental type of something I suspect is likely going to subtly break?

For example, a simple and widely used utility like fast-deep-equal - is this comparison going to work as intended?

How about this?

I mean, I completely see your point, but I also feel like maybe the type-checking problem in JS is a problem that has been allowed to compound over many, many years - it might be time to step back and reflect on the problem itself, rather than coming up with yet another case-specific solution for a new feature.

I realize that's completely beyond the scope of this proposal, I'm just putting the thought out there. I think, no matter what we do here, ergonomically, it's going to be less than ideal - it's important however that this doesn't disrupt the ecosystem. I'd be extremely concerned and careful about changing the behavior of typeof - and, in a sense, changing the behavior of typeof perhaps ought to be seen as equally beyond the scope of this proposal as the broader type checking problem I'm talking about. 🤔

@acutmore
Copy link
Collaborator Author

I don't think syntax is worth it without ===

One of the reasons I'm still including syntax is because otherwise the API ends up creating two objects every time, considering how many developers wanted to use R&T to help with performance concerns I think that they would also appreciate that #{} directly allocates one object but Composite({}) allocates two.

@bloodyowl
Copy link

Overall I'm not sure it'd be worth to add the composites concept at all if it (roughly) boils to down to sugar for Object.freeze, a deepEqual function (isComposite) and a hashing mechanism for maps & sets.

IMO, the main value of the initial proposal is to leverage the inherent properties that deep-immutability give us.

I'd say we could have the best of both worlds in terms of ergonomics with the following (and ideally computing the record/tuple hash at creation, to allow for really fast comparisons):

const record = #{ x: 1 };
const tuple = #[1]

tuple === #[1]       // true
record === #{ x: 1 } // true

Object.is(tuple, #[1])       // true
Object.is(record, #{ x: 1 }) // true

typeof record; // "object"
typeof tuple;  // "object"

Object.isFrozen(record); // true
Object.isFrozen(tuple);  // true
Array.isArray(tuple);    // true

Object.getPrototypeOf(record); // `Object.prototype`
Object.getPrototypeOf(tuple);  // `Array.prototype`

This would make the semantics more consistent, and provide retro-compatibility with existing code in the ecosystem (syntax is opt-in, doesn't introduce a new typeof value).

@nicolo-ribaudo
Copy link
Member

The reason that this proposal was stuck for so long was the desire of having === perform comparison between records/tuples. The fundamental question is: is it worth to have this proposal if #{ x: 1 } !== #{ x: 1 }?

Any approach where they are === will leave the proposal in the current stuck state.

@bloodyowl
Copy link

Is there anywhere we can read about what exactly is the problem with having ===? What were the considered design?

@acutmore
Copy link
Collaborator Author

@slorber
Copy link

slorber commented Feb 26, 2025

@bloodyowl

=== behavior has been discussed here lately: #387


One of the reasons I'm still including syntax is because otherwise the API ends up creating two objects every time, considering how many developers wanted to use R&T to help with performance concerns I think that they would also appreciate that #{} directly allocates one object but Composite({}) allocates two.

@acutmore , if we don't have === then it's not clear how R&T help with performance concerns at all.

I also agree with @ljharb that it might not be worth it to add syntax sugar since the use-case is much more limited to things like composite keys in maps. Without ===, I feel like this feature is less likely to receive mainstream adoption, and I don't necessarily think I'll use inline records using #{} syntax in as many places as with === support.

However there's still an interesting property that's useful for semantics more than performance. In an algo like shallowEqual(obj1,obj2), frameworks like React could use Composite.equal() instead of === to compare object properties. It won't be particularly fast (according to what you aid above) but it remains something valuable to convey meaning to the underlying tools relying on comparisons to detect changes.

One example: it could be a possible replacement of something like React use-deep-compare-effect lib to a natively supported variant:

// Effect called after each React re-render
React.useEffect(fn,[{hello: "world"}])

// Effect called only after React first render
React.useEffect(fn,[#{hello: "world"}])

The fundamental question is: is it worth to have this proposal if #{ x: 1 } !== #{ x: 1 }?

I think it could be, eventually.

But for that, we also need a way to compare large Records & Tuples efficiently. Otherwise, it becomes quite useless for performant change detection systems that many frameworks rely on.

Ultimately, what I want is:

const payload1 = await apiCall("/").then(toComposite)
const payload2 = await apiCall("/").then(toComposite)

compare(payload1,payload2); // This should be fast

As far as I understand, the spec doesn't guarantee any fast comparison mechanism. And the exposed primitives do not permit us to easily implement one ourselves.

If there's already a hashing mechanism involved to index records in maps/sets buckets, exposing it could enable faster comparisons in userland.

function compare(composite1, composite2) {
  return Composite.hash(composite1) === Composite.hash(composite2) 
           && Composite.equal(composite1, composite2);
}

If there was a way to put a composite in a WeakMap, we could memorize the hashes to avoid recomputing them on every comparison.

But this would be better if this efficient comparison system was built in. On composite creation, why not lazily compute a hash in the background and memoize it? Afaik the objects are immutable so the hash should not change. Yes, there's a cost to doing so, but it can be done with low priority, or even be delayed until the first comparison.

@acutmore
Copy link
Collaborator Author

and ideally computing the record/tuple hash at creation, to allow for really fast comparisons):

A hash value does not make comparisons faster in general. It only allows for a fast bail out when two values have a different hash. When two values have the same hash value then they still need to be compared in their entity. So the equality is a linear operation.

@acutmore
Copy link
Collaborator Author

This is already a huge win to have a fast bail out.

The Composite.equal will also have a fast bail out for the best case input.

@slorber
Copy link

slorber commented Feb 26, 2025

The Composite.equal will also have a fast bail out for the best case input.

If that's the case and is part of the spec or at least implemented in practice by all browsers, good news.

What does "best case input" mean? Do you mean it (likely) bails out when we compare 2 different values like Composite.equal(#[1],#[2]) ?

But @littledan said this earlier:

Composite.equal is expected to be linear time, like any deepEqual function.

@bloodyowl
Copy link

Hash values in JS engines are typically 32bit integers. 32bits is not enough to give every possible record/tuple value a unique hash, therefore there will be different values that have the same hash.

sure, but we can imagine the engine searching for an identifier (or assigning a new one if non existent) based on the object structure with some kind of AVLTree at record/tuple creation time, that would give likely give a satisfying performance tradeoff to users.

@nikeee
Copy link
Contributor

nikeee commented Feb 26, 2025

In addition to what others have already stated: It should be easy to define a composite type in statically typed use-cases like TS, Flow and JSDoc.
Specifying a composite type should have a low barrier. This probably means something like fn(a: composite-tuple<a: string>) (randomly chosen syntax). This may be more difficult if typeof composite === "object" but straightforward if typeof composite === "record".

If this is not the case, I (and probably other developers) would defensively write Composite.isComposite(v) ? v : Composite(v) in many places, even when it might not be necessary.

@rauschma
Copy link

Based on what I’m reading about TC39’s opinions, I’ve shifted my initial mental model:

  1. from “tuples and records”
  2. to “support for by-value comparison of objects as keys in collections” (with Set elements basically being keys). That requires those objects to be immutable.

(1) would be nice but would also considerably change the language and cause issues with existing code.

I’ve wanted (2) since 2015, so I’d still be happy.

A class that produces objects that are compared by value would work like this, right?

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    return Composite(this);
  }
}

There could also be a decorator:

@Composite
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

@bloodyowl
Copy link

@rauschma

Is:

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    return Composite(this);
  }
}

fundamentally different from

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    return Object.freeze(this);
  }
}

?

@rauschma
Copy link

@bloodyowl My impression: Composite() would additionally add an internal marker that says “compare this object by value in a collection”. Along, possibly with a hash code(?)

@acutmore
Copy link
Collaborator Author

Yes, Composite is different from Object.freeze in two ways:

  • it creates a new object
  • the composite has a hash value based on its content not its address

@bloodyowl
Copy link

It seems to me that it's doable user-land with a similar DX, is it worth adding a new concept in the language?

@acutmore
Copy link
Collaborator Author

Record({}) === Record({}) is also doable in user-land, this is how the R&T polyfill works.

One reason to do this in the language is co-ordination. Libraries and frameworks can say they use Composite.equal, rather than each specify their own slightly different form of equality.

@bloodyowl
Copy link

bloodyowl commented Feb 26, 2025

Record({}) === Record({}) is also doable in user-land, this is how the R&T polyfill works.

It's indeed doable in user-land, but impossible to reach the DX of #{} === #{}, whereas overall, exposing a similar API as Composite by wrapping Object.freeze & a hashing function gives a similar experience for most of the use cases.

The cost of new syntax should IMO be balanced with a net plus in ergonomics.

@mindplay-dk
Copy link

forgive me, but I'm surprised the behavior of #{} === #{} is even in question.

besides immutability, isn't it typically the point of records and tuples that they have value semantics?

123 === 123 and "lol" === "lol" - and so, naturally #{} === #{}, since these are distinct values just like 123 or "lol".

is it more a matter of what is practical to implement? or what am I missing?

@acutmore
Copy link
Collaborator Author

#387 and these minutes are probably the best place to read through the updates on value semantics.

@demurgos
Copy link

demurgos commented Feb 26, 2025

The reason that this proposal was stuck for so long was the desire of having === perform comparison between records/tuples. The fundamental question is: is it worth to have this proposal if #{ x: 1 } !== #{ x: 1 }?

Any approach where they are === will leave the proposal in the current stuck state.

I shared my position in the past: I think that deep integration in the language is very important for this feature.

The current proposal offers a satisfying answer to composite keys in Set and Map. It also provides some mitigation to protective copies. Allowing records/tuples to contains regular object refs is not a big issue to me, it just means that they're compared by reference as usual. I still think however that without === and out-of-the-box equality semantics everywhere it would be a niche/worse feature.

Yes, Composite is different from Object.freeze in two ways:

  • it creates a new object
  • the composite has a hash value based on its content not its address

I've been carefully following this proposal for years. My understanding is that the main blocker for === are performance concerns by engine implementers. This is probably why the discussion focuses so much around how the equality is actually performed. I saw a lot of discussion around hashes and interning, but there's a point that I actually don't remember having seen which feels fairly obvious for a faster impl and to cover the use case of partial updates (important in many web frameworks relying on equality checks for invalidation): structural sharing.

In particular, the following code is fairly common:

const appState = #{foo: #{...}, bar: #{value: 1}, qux: #{...}}; // some large object containing the app state

const newAppState = updateBar(appState, 2);
function updateBar(appState, newVal) {
    return #{...appState, bar: #{value: newVal}};
}

if (newAppState === appState) {
  return; // no need to refresh the view
}
// else refresh view
// ...
if (newAppState.bar === appState.bar) {
  // no need to refresh the view parts depending on bar
}
refreshBar(newAppState.bar);

I claim that the newAppState === appState is probably not a performance problem in practice when you use partial updates, even if appState is large. The reason is that only a tiny part of it changed. The keys foo and qux still have their old value and could have their old address. The equality check would probably be something like:

// engine code
compositeEqual(left, right): boolean {
  if (hashCode(left) !== hashCode(right)) {
    return false; // early bail-out on different content
  }
  // refefential equality check, as an impl detail, not exposed to user code
  if (addressOf(left) === addressOf(right)) {
    return true; // early bail-out on same address (implies same content)
  }
  return expensiveEqualityCheck(left, right);
}

This means that it's still possible to prune large parts of the comparison when there are shared values by eagerly noticing that the address is the same. Partial updates where pruning based on the address is possible in the engine, is a common case.

This then immediately brings another consideration: if reference semantics are not exposed on R/T values, then you can lazily merge values with the same content into the same address. If Records/Tuples never expose reference semantics, then you actually only need to pay the price of the first comparison. This relies on the fact that the same content can be present at different addresses, but a single address only point at the same content.

What I mean in practice, is that the comparison above can be extended to:

// engine code
compositeEqual(left, right): boolean {
  if (hashCode(left) !== hashCode(right)) {
    return false; // early bail-out on different content
  }
  // refefential equality check, as an impl detail, not exposed to user code
  if (addressOf(left) === addressOf(right)) {
    return true; // early bail-out on same address (implies same content)
  }
  const result = expensiveEqualityCheck(left, right);
  if (result) {
    // move the address of `left` into `right`, so they now become the same object
    *right = left;
    // obviously this relies on the GC to also drop the content of right if it was the last pointer; or to update all handles to `right`

    // you now get:
    assert(addressOf(right) === addressOf(left));
  }
  return result;
}

The implication is that the following can actually be fast:

const appState = #{foo: #{...}, bar: #{value: 1}, qux: #{...}};
// then imagine we build the new app state totally independently of `appState`, so no partial update like before
const newAppState = #{foo: #{...}, bar: #{value: 1}, qux: #{...}};

function refreshComponent(component, oldState, newState) {
  if (newState === oldState) {
    return;
  }
  // ...
}

for (const component of componentsToRefresh) {
  refreshComponent(component, oldState, newState);
}

In the loop above, the deep equality checks happens during the first iteration. It then notices that appState and newAppState actually have the same content. newAppState is updated by the engine to now point to appState; and user code is not able to see the difference. In the following iterations, both records internally use GC handles pointing to the same content so the comparisons are all fast paths.


I know that the spec can't require performance characteristics, but perf concerns are the argument to block === so I hope it makes sense why many messages (including this one) are discussing it. The fact that strict value semantics allow to cache comparison results by updating the handles to internally share the same content is an important optimization of persistent/immutable structures and I did not see it mentioned anywhere else before. If the spec mandates that #{ x: 1 } !== #{ x: 1 }, then it prevents such implementation trick because it leaks that the addresses are different.

Overall, I'm not fully convinced by the vendor perf arguments from vendors and worry that the current direction of dropping support of === will create a chicken-and-egg situation where the feature will remain a niche one, so there will be low incentive to optimize, so there will be lesser incentive to use it. The more the feature is integrated to the language, the more it will be used, and the more there will be incentives to optimize it like strings instead of leaving it to languish.

I understand that reaching such mature state will take years, but I still find it preferable to have deep integration with initially lower perf and possible optimization in the future; rather than lower integration and optimizations prevented by the spec forever.

@ljharb
Copy link
Member

ljharb commented Feb 26, 2025

My understanding of the engines' concerns is literally about "making any changes to how === works", and not about the comparison mechanism at all.

@acutmore
Copy link
Collaborator Author

#{ x: 1 } !== #{ x: 1 }, then it prevents such implementation trick because it leaks that the addresses are different.

Memory addresses are not leaked in JS, there is no way to observe the address of an object. Content sharing is still possible with this design. The header section of multiple composite objects could theoretically re-use the same table.

@demurgos
Copy link

demurgos commented Feb 26, 2025

I did not say that it "leaks addresses", I said that it "leaks that the addresses are different". You can also read it as identities/references to use the JS semantics. This thus prevents deduplication as the identities must remain distinct.

This is a big difference compared to the previous proposal where it was impossible to see different identities of R/T values with the same content.

My understanding of the engines' concerns is literally about "making any changes to how === works", and not about the comparison mechanism at all.

Could you provide some quote or reference regarding making any changes? The main reason why my understanding are perf concerns is the following comment from the 2022-11 meeting (and discussions around it):

SYG: Yes. Just like. All right, I want to give some more color on the complexity concerns from V8. When we chatted about this internally. I think overall - so this is strictly scoped to to kind of complexity and performance concerns. not to the language design concerns with speaking to the complexity concerns only we think the main source of complexity is this equality operator overloading and specifically things that you might do to speed it up if there were performance pressure like interning, and the more we looked at it became this fractal of complexity all kind of stemming from that. I think outside of operator overloading and interning complexity is probably not too bad. But if we have operator overloading for === equality and because it has syntax support we naturally assume, even though we have worked with the champions to kind of temper performance expectations on that triple equals and will be magically fast, but, and they have taken that to heart and we're very happy for it. But because there is syntax for it, suppose that there were just naturally arising ecosystem pressure to make triple equals fast the way we do that is interning, Of course, some complexity is there interning the more we looked at how to intern, something like records of tuples, It is incredibly complicated. And one recent thing that we learned that we didn't think about before is because of this whole immutable data use cases for templates, where you want to have like, this is where the champions moved from these boxes - moved from symbols and which map keys to boxes back to samples as weakmap keys. If you were to have this weak edge because you can have something in the record and tuple that is to solve a primitive that can participate in a weak edge via WeakMap. Now, the intern table has to participate in the fixed point ephemera marking that engines have to do for weak tables like no other interns like the string in turn table does not have to do that. This will be a just a new kind of complexity that it's unclear what the ramifications of our yet. This is just to say like basically all of the complexity that we identified in V8 when chatting internally about this kind of comes out of ===.

I read it as the following:

  1. Using the === means that users will expect good performance
  2. Good performance is complex

I don't read it as a veto on ===, more that it should be worth the effort.

@bakkot
Copy link
Contributor

bakkot commented Feb 26, 2025

=== cannot be made consistently fast without complex interning schemes, which engines are not currently willing to do. And engines are not willing to make === be not consistently fast. All three major engines have said this.

You can continue to argue for it if you would like, but I think it's off-topic for this thread, which is about an alternative proposal which can make progress in a world where changing === isn't viable, which is the world we're in.

@demurgos
Copy link

demurgos commented Feb 26, 2025

=== cannot be made consistently fast without complex interning schemes, which engines are not currently willing to do. And engines are not willing to make === be not consistently fast. All three major engines have said this.

Then I have the same question: could you please share some quotes or references from the major engines? Do you have access to some new updates since the 2022-11 meeting?

Further in the 2022-11 meeting, members working on SpiderMonkey and V8 said:

YSV: In fact, instead what we will end up with is a SpiderMonkey, will swallow the cost. I believe that each implementation will need to make that decision for themselves. But only in the case that we are fully convinced that value types are the right solution here because - sorry, DE. I'm going to sort of reference your queue item here. The argument is that the value types are a result of the goal, not the goal itself. [...]

[...]

SYG: [...] But, you know, I had this is I don't want to say no way, it won’t happen, but currently we don't think it's worth it. [...]

SpiderMonkey said they would implement it if it ends in the spec, despite the cost. V8 is more opposed, but they don't give a definitive no there either. Are there some clearer messages where === was definitely rejected?


My understanding is that there's no publicly available discussion where === was rejected. As such, I treat the 2025-02 presentation as an exploration of alternatives and I don't think that the mechanism to compare R/T values is out of scope. In particular, structural sharing and deduplication were not mentioned previously so I think that it was relevant to the overall conversation. The fact that the new proposal introduces different identities is an important departure of the previous proposal, and on-topic. I'm also not rejecting the new proposal for the sake of it. I'm instead trying to constructively engage with it and its implications. Even if I disagree on one point, I'm still happy that it was present and appreciate that it provides an answer to the keying problem.

@acutmore
Copy link
Collaborator Author

SpiderMonkey said they would implement it if it ends in the spec

It is (partially) implemented in SpiderMonkey, behind a compile time flag. As work began on adding JIT support, as did the concern. There was a lot of complexity, not only to finish writing but to also maintain in the future.

There may be a chicken and egg situation here, however JavaScript is a special language in this case. JavaScript can't break the web, once something is released it has to be supported forever. Other languages can try something, and if it isn't adopted then they can do a deprecation cycle.

@bakkot
Copy link
Contributor

bakkot commented Feb 26, 2025

My understanding is that there's no publicly available discussion where === was rejected.

"currently we don't think it's worth it" is a rejection. You're rarely going to get a less equivocal rejection

structural sharing and deduplication were not mentioned previously so I think that it was relevant to the overall conversation [...] The fact that the new proposal introduces different identities is an important departure of the previous proposal, and on-topic.

I agree, and it's totally reasonable to say "I don't think this proposal worth doing without ===". But this isn't the right place to argue about whether === can be done: this is a proposal for the world where it can't. If you want to argue about how to implement ===, #387 would be a better place.

(Re: structural sharing, I think it wasn't mentioned before simply because everyone's already been assuming it and it doesn't really change anything, since the concerns are about the worst case. But if you want to talk about that more, again, #387 would be a better place.)

@Maxdamantus
Copy link

Maxdamantus commented Feb 26, 2025

Record({}) === Record({}) is also doable in user-land, this is how the R&T polyfill works.

One reason to do this in the language is co-ordination. Libraries and frameworks can say they use Composite.equal, rather than each specify their own slightly different form of equality.

This aspect has actually made me feel a bit uncomfortable. Not having === behave "correctly" or "desirably" for certain values probably means there'll be a preference going forward to use the new comparison operation, which is Composite.equal.

This is similar to how modern JS practices recommend always using a === b instead of a == b, because it's correct in more cases. This can also be compared to Java where the "correct" comparison is often a.equals(b) rather than a == b.

It just seems like the evolution of the "correct" way to compare things is getting uglier and uglier:

  1. a == b 🙂
  2. a === b 😐
  3. Composite.equal(a, b) 🙁

@bakkot
Copy link
Contributor

bakkot commented Feb 26, 2025

@Maxdamantus Would you have the same concern if we added an Object.deepEqual, as has sometimes been requested? I would expect people not to reach for that except when they actually want it, just as they don't reach for Object.is except when they actually want it. I expect the same for Composite.equal.

@bloodyowl
Copy link

You can continue to argue for it if you would like, but I think it's off-topic for this thread, which is about an alternative proposal which can make progress in a world where changing === isn't viable, which is the world we're in.

if this world isn't viable, I think that adding concepts and semantics that will eventually make the possibility of adding this in the language even harder if at some point vendors accept the idea should be a no go.

@Maxdamantus
Copy link

@bakkot Object.deepEqual to me sounds like it would traverse the contents of mutable objects/arrays, ie Object.deepEqual([{}], [{}]) === true.

I'm generally sceptical of "deep X" concepts, and feel like such a function is difficult to reason about correctly. In the case of deepEqual above, this is conceptually ugly to me, since a and b might be considered "deeply equal" even though modifying a might be different to modifying b.

I see R/T as a way of addressing the cases where you would want to use a function, since like @rauschma said (#393 (comment)), it allows you to tag the structures that you actually want to be compared by-content. This comes at the requirement[0] of enforcing shallow (not deep!) immutability, since otherwise equality is unstable (the equality of two values can change over time, which can cause issues in various datastructures, whether built-in or user-level).

I've always been in favour of the normal R/T comparison treating existing mutable objects as distinct, whether that means #[{}] !== #[{}] or !Composite.equal(#[{}], #[{}]). My current concern is just that it seems like this version of the proposal means you've got behaviour for === and !== that you almost never want.

Anyone familiar with Java should recognise this problem, as the way to compare string objects there is a.equals(b) or Objects.equals(a, b), and a == b is almost always a nonsensical operation for strings. Note that string objects in Java are also immutable, so distinguishing distinguishable strings only seems to exist for its own sake[1].


[0] I see shallow immutability primarily as a fundamental requirement for sensible by-content comparisons, but enforcement of immutability is also nice interfacially. Both of these points already apply to strings, where it's nice that strings are always compared by content, and it's nice knowing that if I pass a string to someone, they can't modify my view of the string.

[1] inb4 someone mentions synchronized ("some unique string".intern()) { ... }

@DominoPivot
Copy link

DominoPivot commented Feb 27, 2025

In a world where records and tuples are primitives, I understand wanting to overload the behavior of === to match the behavior of strings. But if records and tuples are a special case of frozen objects, and may still hold references to mutable objects, then I would definitely expect === to compare them by identity, just like Object.is.

My one concern is that, in the previous proposal, typeof x === "record" provided an easy way to check for deep immutability. If composites can be made of mutable objects, then how would one ensure a value is deeply immutable? It would be a shame if, after all of this, we still found ourselves making preemptive deep copies of records, or having to implement recursive checks ourselves; I was quite happy to see this being solved at the language level once and for all, which I thought was the whole point of tuples and records to begin with.

@acutmore
Copy link
Collaborator Author

If composites can be made of mutable objects, then how would one ensure a value is deeply immutable?

This is mentioned here: #393 (comment)

I will advocate for this, as I do believe it's an important part of the proposal for the reason you describe.

We could potentially take one bit from the hash code and use that to store the "deep" flag.

@spartanatreyu
Copy link

That is correct. A composite would not be a guarantee of deep immutability. At this point in JS's life I think the ship has sailed

I'm not sure what the point of a composite is or what it brings to the table if they're not deeply immutable.

What is the point of a record/tuple/composite, if it holds mutable data?

Users keep needing to reinvent and reimplement immutable data structures. Is this not a path that should be paved by the language and browser engines?

@bakkot
Copy link
Contributor

bakkot commented Feb 27, 2025

What is the point of a record/tuple/composite, if it holds mutable data?

The point is to be able to use them as composite keys. Like Map.groupBy(iter, v => #[v.x, v.y]), as in the OP. You want to be able to do this with composite keys holding mutable data for the same reason you want to be able to key a Map by a single mutable object.

@Maxdamantus
Copy link

What is the point of a record/tuple/composite, if it holds mutable data?

In addition to composite map keys, if you're familiar with React hooks, there's a concept called the "dependencies array" which is essentially treated as a tuple, for the sake of detecting when values have changed:

React.useEffect(() => {
    elem.appendChild(document.createTextNode("hello"));
}, [elem]);

The above call to React.useEffect might be executed multiple times, but elem might have the same value. React will ignore these repeated calls, based on a by-content comparison of the [elem] array. Currently this will be done by React itself where it will walk through the old array and the new array, calling Object.is on each corresponding pair. If the "dependencies array" is instead a tuple it would just call Object.is(old, new). This is all agnostic of whether the inner contents are mutable (in my example they are).

This sort of interface would be simpler in an R/T world (assuming mutable objects are allowed), since they would just have a "dependency value", and the user would be more free about how they express the structure:

  • elem
  • #[elem]
  • #{ elem }
  • #[elem, otherDependencyValue] (note that otherDependencyValue might be another composite containing mutable objects)

This is just one example that might be relatable to a lot of people in the JS ecosystem, but in general I think if it makes sense to compare references to mutable things (where "equal" means same reference, that using either is basically equivalent), it should also make sense to compare multiple references to mutable things.

Usually APIs (eg, Map, Set, Array.prototype.indexOf) will only let you pass a single value for comparison, but sometimes you want to pass multiple values. React builds this assumption into its APIs (as the "dependency array" concept) but imo this makes the API more complex.

In theory Map could have had multi-value keys (so you'd always have to use eg, map.set(["key"], "value")), but imo that API is more awkward in cases where you don't need it.

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests