15:32
<Evan Winslow>
Two other proposals (protocols and structs) currently provide for a new way to define methods. We could simplify both of these considerably I think by just dropping the methods from them and deferring to call-this.
15:33
<Evan Winslow>
Regarding the conflict between call-this and extensions, I believe this can be resolved by removing one of the syntactic options from call this: o..ns.m(). If we instead require this to be o..(ns.m)(), then there is no conflict.
15:36
<Evan Winslow>
I was wondering if we could get away with only allowing o..m() and not having a place for arbitrary expressions there at all. "Just use a temporary const" and all that.
15:45
<Evan Winslow>
Then there's also the apparently steady stream of proposals for adding methods to built-in types. Having "extension methods" (via call-this) provides a compelling way to try things out without standard monkey-patching risks.
17:37
<rbuckton>
* Actually, there is even a fourth style, tacit/point-free functional programming, which I happen to be a fan of. Tacit FP is what F# pipes, partial function application syntax, and Function.pipe would have encouraged. But several engine implementors have pushed strongly back on it due to concerns about hot-path function allocation, GC, and optimization complexity. This is an example of TC39 (or at least several very important members of it) actually discouraging one of the styles. We still have three styles left to consider. And the pipe operator works with higher-order functions, if you’re willing to attach an explicit call (##) to your HOF…
IMO, the likeliest case for hot path function allocation would have been ar |> f(?), which could be optimized away in the spec and not just in implementations. Even then, pipes like this are more commonly outside of loops. Instead, it's the f that would contain the loop (i.e., map/filter/etc.).
17:40
<rbuckton>
Optimizing it away in spec could have been detecting PipelineExpression : LeftHandSideExpression `|>` PartialApplicationExpression in an SDO and transforming it to an immediate evaluation rather than a function allocation.
17:44
<rbuckton>
That would cause issues with existing libraries preferring a mechanism for gradual adoption of shared structs in their codebase, as well as Shu's oroginal hopes for structs to be better classes due to their fixed layout and initialization order guarantees. Call-this would be a major impediment to adoption, IMO.
18:31
<jschoi>
Regarding the conflict between call-this and extensions, I believe this can be resolved by removing one of the syntactic options from call this: o..ns.m(). If we instead require this to be o..(ns.m)(), then there is no conflict.
I’m open to requiring parentheses around property access chains in call-this’s RHS, in the spirit of compatibility with the extensions proposal. And it can always be added back in extensions continues to fail to progress.
I would also be interested to know how Jordan feels about it for his use cases.
18:31
<jschoi>
I was wondering if we could get away with only allowing o..m() and not having a place for arbitrary expressions there at all. "Just use a temporary const" and all that.
Is there a particular reason not to allow parenthesized arbitrary expressions in the RHS?
18:33
<Evan Winslow>

More examples:

  • RxJS went from Methods to HOF.
  • Angular team tells me they write their API in a weird way in order to be tree-shakable, though they don't like that they have to do that.
  • Zod library is method-based, and Valibot was created as a tree-shakable alternative.
  • _.chain from Lodash is method-based, but they of course export a whole separate library of tree-shakable functions also for people who want that.
  • Colors (by Lea Verou) has an OO-methods-based API but it's huge so they export a separate set of functions that are more tree-shakable.

So yes, I see a strong and consistent trend of people having method-based APIs then needing to back out and switch to function-based APIs for tree-shaking / performance.

18:35
<Evan Winslow>
And they all, to a fault, do it by sacrificing ergonomics and going to functions, which are much more reliably tree-shakable, not by giving "hints" to bundlers to be more aggressive.
18:38
<Evan Winslow>
And given all the articles online about "barrel files considered harmful" it is apparent to me that many, many folks are quite willing to change their code in order to get more reliable tree-shaking.
18:39
<Evan Winslow>
A commitment to aggressively simplifying the proposal in every conceivable way :)
18:43
<Evan Winslow>
The way Jordan explained his use-case to me, it was all about defensiveness against mutated prototypes. So something like myArr..Array.prototype.slice(...) doesn't accomplish that anyways. Nor does myArr..(Array.prototype.slice)(...). You need a top-level const {slice} = Array.prototype; and then later on invoke with myArr..slice(...).
18:45
<Evan Winslow>
So maybe that is another reason to not have the parenthesized o..(ns.m)() option at all. But yes, would be great to hear from him directly.
18:50
<Evan Winslow>
Hmm, that would of course be disappointing. I guess I was hoping it would be the inverse -- structs lacking prototype methods would be one more motivation to switch existing libraries to tree-shakable methods. So you are just all around unlocking really really big performance benefits out of making the switch to better tree-shaking.
19:09
<Evan Winslow>
One reason in favor of leaving the parenthesized RHS syntax is actually bundlers: To faithfully represent ESM semantics, they tend to lazily do property access on the module object. So you end up seeing a lot of (0, ns.fn)(...args) in the bundled code to simulate calling a named import without accidentally setting this to the module object ns
19:22
<rbuckton>
One of the requirements we have for structs is the ability to maintain private state, which can only be accessed by methods and functions declared on the struct itself, just as on classes.
Struct methods currently have some unique differences to class methods which are intended to be improvements over normal class methods, however. For one, struct instance methods are intended to have a fixed this type that is enforced at runtime so as to reduce polymorphism. In addition, shared structs are currently specified to only be legal at the top level of a module, so no shared struct expressions or shared struct declarations inside functions or blocks. This means that shared struct methods are guaranteed to be placed on a declaration at the top level, which will help improve static analysis of these methods as a knock-on effect.
19:30
<rbuckton>
Awhile back I suggested we consider introducing a Symbol.geti/Symbol.seti protocol in conjunction with slice/index-from-end notation, which could be defined on an object intended to be used as a key. I wonder if something like that could be leveraged to allow you to just use a function as a key: myArr[Array.prototype.slice](...)
19:36
<Evan Winslow>
Ah, interesting. Can you say more? Which object would have the Symbol.geti? myArr or slice? and what would be the implementation?
19:37
<rbuckton>

The idea was that you could have a Slice class and an Index class that supported these symbols, and that you could use x:y literal syntax to produce a Slice and ^x syntax to produce an Index computed from the end of an array, such that the following are equivalent:

myArr[0:1];
myArr[new Slice(0, 1)];
new Slice(0, 1)[Symbol.geti](myArr);

myArr[^1];
myArr[new Index(1, true /*fromEnd*/)];
new Index(1, true)[Symbol.geti](myArr);

A Symbol.geti for a function could just be a call to bind:

myArr[Array.prototype.slice](1, 2);
Array.prototype.slice[Symbol.geti](myArr)(1, 2);
Array.prototype.slice.bind(myArr)(1, 2);

Or a possible Symbol.calli could intercept invocation:

myArr[Array.prototype.slice](1, 2);
Array.prototype.slice[Symbol.calli](myArr, 1, 2);
19:38
<rbuckton>
slice would, or rather, any function would. The symbol just avoids depending on bind or call directly to avoid the issue with then we see with Promise.
19:39
<rbuckton>
The downside is that putting it on all functions would possibly change existing behavior, in which case some other token would be necessary to opt in. i.e., myArr[::Array.prototype.slice](1, 2), or similar.
19:40
<rbuckton>
either way, you need to use something like () or [] to bracket the expression. This just sticks to using [] since that's familiar to JS users.
19:40
<Evan Winslow>
Urf, right, because myArr[sliceFn] already means myArr[String(sliceFn)]?
19:40
<rbuckton>
Yeah.
19:42
<rbuckton>

But this could be as simple as a wrapper function. i.e., if we had Symbol.geti already, someone could just write:

const wrap = f => ({ [Symbol.geti]: obj => f.bind(obj) });

myArr[wrap(Array.prototype.slice)](1, 2);
19:44
<rbuckton>
The intermediate object and the call to .bind are obvious downsides, however.
19:46
<rbuckton>
I wonder how often people actually try to use a function as a key in an object.
19:49
<Evan Winslow>
I had not seen or considered this idea of making o[fn]() work before. Although it technically has an existing meaning, maybe there's a chance no one uses it like that... I will write it down as an alternative to consider.
19:50
<Evan Winslow>
Oh I guess another potential concern here is "this would slow down all existing [] access," since they would all have to first check for Symbol.calli/geti/seti now.
20:06
<Evan Winslow>
Ah interesting! I was just musing with Ashley Claymore plenary about a potential new syntax for declaring extension methods that would grant access to private state, but in a tree-shakable way. And I also imagined enforcing the this type. I wasn't sure if that would be too much of a performance penalty (runtime checks being expensive) or a performance win (megamorphic functions trigger their own special kind of slow). Let me find it...
20:06
<jschoi>
I recall that they were concerned about observably different stack traces making optimizations like that complex or not possible. You might remember what happened there better than me.
20:08
<rbuckton>
I don't think it would, actually. We would only resolve geti on objects, so it wouldn't effect string, symbol, and number, which are the lion's share of uses. Anything else already requires a user-code carve out for toString
20:09
<Evan Winslow>
class C {
  #x = Math.random();
  #y = Math.random();
}

extends C {
  length() {
    return Math.sqrt(this.#x**2 + this.#y**2);
  }
}

console.log(new C()..length())
20:12
<rbuckton>
You don't want just anyone to be able to reopen your class and access your private state. It defeats the purpose of strong privacy.
20:12
<Evan Winslow>
Correct. So this would be scope-limited
20:12
<rbuckton>
I considered WeakMap with geti/seti, but that wouldn't work for structs.
20:13
<Evan Winslow>

E.g.

import {C} from '...';

extends C {
  length() { this.#x } // lol no, syntax error, go away
}
20:16
<Evan Winslow>
I'm curious how you envisioned enforcing the this type at runtime without too much performance penalty?
20:16
<Evan Winslow>
Is it just a basic instanceof check or something fancier?
20:16
<rbuckton>

If you're in the same scope, you don't need those if we already have static {}:

let getX;
class C {
  x = ...;
  static { getX = c => c.#x; }
}

C.prototype.length = function () { return getX(this); };
20:17
<rbuckton>
Shared structs have a fixed shape. If the this does have the same fixed shape it throws.
20:20
<Evan Winslow>
So the browser has maybe some cache of the shape for the struct, which cannot be changed. Technically, two structs with the same shape could share each other's methods, but then why would you do that because your struct already has the needed method?
20:22
<Evan Winslow>
Yea I do see how it's already possible but this seems like exactly the kind of case where people will go, "nah, I'll just do the much more convenient non-tree-shakable thing instead"
20:25
<rbuckton>
So the browser has maybe some cache of the shape for the struct, which cannot be changed. Technically, two structs with the same shape could share each other's methods, but then why would you do that because your struct already has the needed method?
There is a type identity tied to the shape, which is used to handle prototypes across threads. You also don't want a third party to emulate your shape to access your private state.
20:27
<rbuckton>
The way it is set up, the only way to access shared state across threads in a shared struct is using a function/method of the struct loaded from the same file in two agents
21:25
<Ashley Claymore>

If you're in the same scope, you don't need those if we already have static {}:

let getX;
class C {
  x = ...;
  static { getX = c => c.#x; }
}

C.prototype.length = function () { return getX(this); };
The idea is to be able to move "methods" out of the declaration so they are exported and imported separately making tree shaking almost trivial.
https://gist.github.com/acutmore/e08fad716e0369dfa0341bde8b8e17d9