-
Notifications
You must be signed in to change notification settings - Fork 60
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
Comments
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 Although I would have hoped for 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 |
|
I see thanks. When using |
The way maps/sets work is that you need to:
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. |
This looks great! A few ideas:
|
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. (
Is this right? Regular objects would be allowed inside composites?
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. |
Personally I like composite keys quite a lot, but I don't think syntax is worth it without |
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 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. |
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 It's possible to imagine an additional API |
Since composites are objects, and not |
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.
It would need a new marker from TypeScript to encode as structurally they are no different from regular objects/arrays. Maybe it would be
Note: 3 and 4 can be done in userland but 2 can't. 2 would break the similarity between Map and WeakMap.
Potentially. Technically all of the APIs could be on |
I really hope it is still time to reconsider to not drop the idea of using |
I'm eagerly waiting for the minutes to see what was discussed; however having a single constructor is consistent with dropping support for
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. 👍
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 |
// 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 ( Same as some others here, I'm also very much in favour of using Disregarding the ergonomic advantages of R/T |
Technically
I believe that's the hope.
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. |
Not keeping 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: ...;
}
} |
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 I can imagine if custom prototypes are used, there would also be some syntax like This can also be compared with other "reliable" ways of detecting certain types of values:
Similar to |
Yes,
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 function measureThing(thing) {
return match(thing) {
when String(str) -> ...,
when Composite([...tupleLike]) -> ...,
when Composite(recordLike) -> ...,
}
} |
FWIW, I feel similar, and would note that if we were to drop the syntax from the proposal, that could free up the |
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 I suggested both We currently use
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 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.
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 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 // 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 if For example, a simple and widely used utility like 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 |
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 |
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 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). |
The reason that this proposal was stuck for so long was the desire of having Any approach where they are |
Is there anywhere we can read about what exactly is the problem with having |
@acutmore , if we don't have 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 However there's still an interesting property that's useful for semantics more than performance. In an algo like 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"}])
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. |
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. |
The |
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 But @littledan said this earlier:
|
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. |
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. If this is not the case, I (and probably other developers) would defensively write |
Based on what I’m reading about TC39’s opinions, I’ve shifted my initial mental model:
(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;
}
} |
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);
}
} ? |
@bloodyowl My impression: |
Yes,
|
It seems to me that it's doable user-land with a similar DX, is it worth adding a new concept in the language? |
One reason to do this in the language is co-ordination. Libraries and frameworks can say they use |
It's indeed doable in user-land, but impossible to reach the DX of The cost of new syntax should IMO be balanced with a net plus in ergonomics. |
forgive me, but I'm surprised the behavior of besides immutability, isn't it typically the point of records and tuples that they have value semantics?
is it more a matter of what is practical to implement? or what am I missing? |
#387 and these minutes are probably the best place to read through the updates on value semantics. |
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
I've been carefully following this proposal for years. My understanding is that the main blocker for 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 // 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 I know that the spec can't require performance characteristics, but perf concerns are the argument to block Overall, I'm not fully convinced by the vendor perf arguments from vendors and worry that the current direction of dropping support of 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. |
My understanding of the engines' concerns is literally about "making any changes to how |
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. |
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.
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):
I read it as the following:
I don't read it as a veto on |
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 |
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:
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 My understanding is that there's no publicly available discussion where |
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. |
"currently we don't think it's worth it" is a rejection. You're rarely going to get a less equivocal rejection
I agree, and it's totally reasonable to say "I don't think this proposal worth doing without (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.) |
This aspect has actually made me feel a bit uncomfortable. Not having This is similar to how modern JS practices recommend always using It just seems like the evolution of the "correct" way to compare things is getting uglier and uglier:
|
@Maxdamantus Would you have the same concern if we added an |
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. |
@bakkot I'm generally sceptical of "deep X" concepts, and feel like such a function is difficult to reason about correctly. In the case of 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 Anyone familiar with Java should recognise this problem, as the way to compare string objects there is [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 |
In a world where records and tuples are primitives, I understand wanting to overload the behavior of My one concern is that, in the previous proposal, |
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. |
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? |
The point is to be able to use them as composite keys. Like |
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 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:
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, In theory |
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:
typeof
)===
After discussing some designs with various delegates last week. I currently have:
The text was updated successfully, but these errors were encountered: