tc39 / proposal-module-expressions

https://tc39.es/proposal-module-expressions/
MIT License
438 stars 18 forks source link

The new function shorthand `module function`. Discuss!! #59

Open surma opened 3 years ago

surma commented 3 years ago

At the October TC39 f2f, I presented a newly added syntax: Module Functions


One core motivation for Module Blocks is providing a language-level primitive that unlocks patterns for parallelism, because JavaScript can’t (easily) adopt the shared memory parallelism model that most other languages utilize.

I did some research into the ergonomics and patterns of other languages and other platforms, and found that many use functions as their fundamental primitive. For example, here’s reactive programming in Swift on iOS and Kotlin on Android:

Observable<Int>.create {
 // ...
}
    .map { value -> value * 2 }
    .observeOn(SerialDispatchQueueScheduler(qos: .background))
    .map { value -> value * 3 }
    .observeOn(MainScheduler.instance)
    .subscribe(onNext: { element -> putOnScreen(element) })
    .disposed(by: disposeBag)
userObservable
    .subscribeOn(Schedulers.io())
    .flatMap { users -> Observable.from(users) }
    .observeOn(Schedulers.computation())
    .map { it.name.length }
    .distinct()
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe { putOnScreen(it) }

While JavaScript shouldn’t be adopting other language’s idioms 1:1 due to the lack of shared memory, I want to enable similar patterns in JavaScript. If we use a RxJS-inspired observable syntax, I think it becomes clear that a full module syntax would be quite noisy for this use-case.

new Observable(subscriber => { /* ... */ })
    .pipe(map(module {
        export default function(value) {
            return value * 2;
        }
    }))
    .observeOn(Observable.Threads.backgroundThread)
    .pipe(map(module {
        // Arrow functions make it slightly better
        export default value => value * 3;
    }))
    .observeOn(Observable.Threads.thisThread)
    .subscribe(element => putOnScreen(element));

To allow JS to implement these patterns in a syntactically lightweight way, I added function short-hand to the proposal that merely acts as syntactic sugar.

new Observable(subscriber => { /* ... */ })
    .pipe(map(module value => value * 2))
    .observeOn(Observable.Threads.backgroundThread)
    .pipe(map(module value => value * 3))
    .observeOn(Observable.Threads.thisThread)
    .subscribe(element => putOnScreen(element));

Thinking along Observables also made me revise my statement in a previous issue: I am not as confident anymore, that import-less module blocks would be that uncommon.

I feel like this is a very lightweight addition to the proposal that can make a lot of common patterns a lot easier to follow in JS.

Looking on input, so pinging some folks I know have opinions! @Jack-Works @domenic @nicolo-ribaudo @guybedford @leobalter

mhofman commented 3 years ago

As discussed during the plenary last week, one big problem with this is default arguments. They appear outside of any "block". While default arguments have always been evaluated within the scope of the function, it'd be particularly surprising for developers that they cannot reference the outer scope, limiting their use to syntax and built-ins (or other globals expected to be set in the execution environment). In effect the module prefix creates an isolated scope without stronger syntactic delimitation.

surma commented 3 years ago

I am sympathetic to that argument. However, I though about it more and I am increasingly wondering how big that problem really is, since — at least in my experience — default values are almost always primitive values, even for more complex function signatures.

module function nearestColor(color, {space = "srgb", numCandidates = 3} = {}) {
    /* ... */
}

A function like this would work as expected.

I am also wondering if there really would be developer confusion without a stronger syntactic delimitation. yield and await only work in functions that have been defined with a specific keyword. I am aware that this is not a perfect comparison, but I’m mostly thinking out loud.

acutmore commented 3 years ago

I think some more real world examples would really help if we want to motivate this. Rather than sending a number to another thread to multiple it by 2.

My main concerns are

ljharb commented 3 years ago

What about strict mode inside the body already applying to the signature? Admittedly when the body is changing from sloppy to strict, non simple lists throw, but it’s still an example of something inside the body affecting how the signature is treated.

guybedford commented 3 years ago

This seems great to use as an elegant way to share code between contexts. I wonder if the arrow function might be overkill though, where the function declaration form is already sweet enough on its own for the most part?

leobalter commented 3 years ago

I like this shorthand a lot and I can see this being used to shortcut small operations done on top of other imports.

leobalter commented 3 years ago

The examples on top are good enough for the shorthand, but we can also use it for shadowrealms for code with small dependencies.

const realm = new ShadowRealm();

const result = await realm.importValue(module async () => {
  const { doSomething } = await import('framework');
  return doSomething(42);
}, 'default');

// instead of
const result = await realm.importValue(module {
  export default async () => {
    const { doSomething } = await import('framework');
    return doSomething(42);
  }
}, 'default');
kriskowal commented 3 years ago

I can say that module blocks might one day be useful to realize promise.there.

remotePromise.there(module local => {
})
mhofman commented 3 years ago

The examples on top are good enough for the shorthand, but we can also use it for shadowrealms for code with small dependencies.

Is this really a representative use case? I'd expect realm imported values to be mostly reusable functions, not simple primitive values. And even if it's getting a primitive result from a scaffolded operation, would imports really be dynamic instead of static?

I think some more real world examples would really help if we want to motivate this.

Same, I'd like to see more real world examples. In my mind, offloading an operation to another thread is not worth it for simple tasks. In that context, making it easier to express trivial tasks as module function is probably opposite of what we should encourage, where the more verbose syntax captures that cost a little more.

However I can also imagine a complex task already defined in another thread requiring parametrization for some operations (e.g. a filter or sort predicate), so a module function syntax would be a natural way to remove that barrier.

Jack-Works commented 3 years ago

Hmm, this doesn't seem like a big change from the previous version (module function (...) {}). And I think the previous version even looks better (it has a function to indicate the visual context) if I have to choose one of those.

surma commented 2 years ago

After some time has passed, I’m leaning towards not adding the shorthand syntax to the first iteration of the proposal. We can still add it later if the real-world usage of module blocks would benefit in a significant way.

streamich commented 1 year ago

It seems the motivation for this syntax sugar is to simplify this syntax:

    .pipe(map(module { export default value => value * 3 }))

to this:

    .pipe(map(module value => value * 3))

Which at first glance does not sound like it is worth the effort. However, it sort of makes sense, following the logic in next 3 steps:

  1. If the module block is a single statement, maybe the brackets can be dropped (similar how it is done in many other constructs):
    .pipe(map(module export default value => value * 3))
  1. If it is a single statement module, maybe it can be assumed that the statement is "exported" so the "export" can be omitted (similar how in single statement arrow functions, the expression is "returned"):
    .pipe(map(module default value => value * 3))
  1. And then if the export name is not explicitly specified, it can default to "default":
    .pipe(map(module value => value * 3))

But then it still does not add up, as map operator in RxJs accepts a synchronous function. But module ... would return a Module, and to get something out of that module it needs to be spun up in some thread first; and to retrieve something from that thread, that would be an asynchronous operation.