ECMAScript Stage-1 Proposal. J. S. Choi, 2021.
This proposal is a resurrection of the old Stage-0 bind-operator proposal. It is also an alternative, competing proposal to the Stage-1 extensions proposal. For more information, see § Related proposals.
The syntax is being bikeshedded in issue #10.
receiver
fn
ns
expr
arg0, arg1
, etc.The syntax is being bikeshedded in issue #10.
In short:
.call
is very useful and very common in JavaScript codebases..call
is clunky and unergonomic..call
is very commonThe dynamic this
binding is a fundamental part of JavaScript design and
practice today. Because of this, developers frequently need to change the
this
binding. .call
is arguably one of the most commonly used functions in
all of JavaScript.
We can estimate .call
’s prevalences using Node Gzemnid. Although [Gzemnid
can be deceptive][], we are only seeking rough estimations.
The following results are from the checked-in-Git source code of the top-1000 downloaded NPM packages.
Occurrences | Method |
---|---|
1,016,503 | .map |
315,922 | .call |
271,915 | console.log |
182,292 | .slice |
170,248 | .bind |
168,872 | .set |
70,116 | .push |
These results suggest that usage of .call
is comparable to usage of other
frequently used standard functions. In this dataset, its usage exceeds even
that of console.log
.
Obviously, this methodology has many pitfalls, but we are only looking for roughly estimated orders of magnitude relative to other baseline functions. Gzemnid counts each library’s codebase only once; it does not double-count dependencies.
There are a variety of reasons why developers use .call
. These include:
Wrapping a receiver’s method before calling it:
// Wrapping a receiver’s method before calling it:
assertFunction(obj.f).call(obj, f);
// From bluebird@3.5.5.
tryCatch(item).call(boundTo, e);
Conditionally switching a call between two methods:
const method = obj.f ?? obj.g;
method.call(obj, arg0, arg1);
// From debug@4.1.1.
// createDebug is an object either for Node or for web browsers.
createDebug.formatArgs.call(self, args);
Reusing an original method on a monkey-patched object:
// From graceful-fs@4.1.15.
return fs$read.call(fs, fd, /*…*/)
Protecting a method call from prototype pollution:
// From lodash@4.17.11.
// Object.prototype.toString was cached as nativeObjectToString.
nativeObjectToString.call(value);
…and other reasons. Developers do all of this using .call
, and the sum of
these uses propels .call
to being one of the most used operations in the
entire language.
.call
is clunkyIn spite of its frequency, .call is clunky and poorly readable. It separates the function from its receiver and arguments with boilerplate, and it flips the “natural” word order, resulting in a verb.call
–subject–object word order:
fn.call(rec, arg0).
JavaScript developers are used to using methods in a [subject–verb–object word order][] that resembles English and other SVO human languages. This pattern is ubiquitous in JavaScript as dot method calls:
rec.method(arg0).
Consider the following real-life code using .call
, and compare them
to versions that use the call-this operator. The difference is especially
evident when you read them aloud.
// kind-of@6.0.2/index.js
type = toString.call(val);
type = val~>toString();
// debug@4.1.1/src/common.js
match = formatter.call(self, val);
match = self~>formatter(val);
createDebug.formatArgs.call(self, args);
self~>createDebug.formatArgs(args);
// rxjs@6.5.2/src/internal/operators/every.ts
result = this.predicate.call(this.thisArg, value, this.index++, this.source);
result = this.thisArg~>this.predicate(value, this.index++, this.source);
// bluebird@3.5.5/js/release/synchronous_inspection.js
return isPending.call(this._target());
return this._target()~>isPending();
var matchesPredicate = tryCatch(item).call(boundTo, e);
var matchesPredicate = boundTo~>(tryCatch(item))(e);
// async@3.0.1/internal/initialParams.js
var callback = args.pop(); return fn.call(this, args, callback);
var callback = args.pop(); return this~>fn(args, callback);
// ajv@6.10.0/lib/ajv.js
validate = macro.call(self, schema, parentSchema, it);
validate = self~>macro(schema, parentSchema, it);
// graceful-fs@4.1.15/polyfills.js
return fs$read.call(fs, fd, buffer, offset, length, position, callback)
return fs~>fs$read(fd, buffer, offset, length, position, callback)
In short:
Very common × Very clunky = Worth improving with syntax.
The answer to whether multiple ways or syntaxes of doing something are harmful critically depends on the duplication’s effect on APIs and how viral it is.
Suppose we’re considering having two syntaxes 𝘟 and 𝘠 to use APIs. If module or person 𝘈 uses syntax 𝘟 which interoperates better with syntax 𝘟 than syntax 𝘠 and that pressures module or person 𝘉 to use syntax 𝘟 in their new APIs to interoperate with person 𝘈’s APIs, that virality encourages ecosystem forking and API wars. Introducing multiple such ways into the language is bad.
“On the other hand, if person 𝘈’s choice of syntax [i.e., 𝘟] has no effect on person 𝘉[’s choice of syntax, 𝘠,] and they can interoperate without any hassles, then that’s generally benign.”
From the 2022-01-27 dataflow meeting.
this
-based ƒs.this
-based ƒs.This schism between 𝘟 APIs and 𝘠 APIs is already is built into the language. The schism is such that prominent APIs like the [Firebase JS SDK have switched][] from 𝘠 to 𝘟 (e.g., for module splitting).
But the call-this operator, together with the [pipe operator |>
][pipe
operator], would make interoperability between 𝘟 and 𝘠 more fluid – and it
would make the choice between 𝘟 and 𝘠 less viral – bridging the schism:
import { x0, x1 } from '𝘟';
import { y0, y1 } from '𝘠';
input |> x0(@)~>y0() |> x1(@)~>y1();
A goal of this proposal is simplicity. Therefore, this proposal purposefully does not address the following use cases:
Function binding and method extraction are not a goal of this proposal.
Changing the this
receiver of functions is more common than function binding,
as evidenced by the preceding statistics. Some TC39 representatives have
expressed concern that function binding may be redundant with proposals such as
PFA (partial function application) syntax. Therefore, we will defer
these two features to future proposals.
Extracting property accessors (i.e., getters and setters) is also not a goal of this proposal. Get/set accessors are not like methods. Methods are properties (which happen to be functions). Accessors themselves are not properties; they are functions that activate when getting or setting properties.
Getters/setters have to be extracted using Object.getOwnPropertyDescriptor
;
they are not handled in a special way. This verbosity may be considered to be
desirable syntactic salt: it makes the developer’s intention (to extract
getters/setters – and not methods) more explicit.
const { get: $getSize } =
Object.getOwnPropertyDescriptor(
Set.prototype, 'size');
// The adversary’s code.
delete Set; delete Function;
// Our own trusted code, running later.
new Set([0, 1, 2])~>$getSize();
Function/expression application, in which deeply nested function calls and other expressions are untangled into linear pipelines, is important but not addressed by this proposal. Instead, it is addressed by the pipe operator, with which this proposal’s syntax works well.
This proposal is a resurrection of the old Stage-0 bind-operator proposal. (A champion of the old proposal has recommended restarting with a new proposal instead of using the old proposal.)
The new proposal is basically the same as the old proposal. The only big difference is that there is no unary form for implicit binding of the receiver during method extraction. (See also non-goals.)
The extensions system is an alternative, competing proposal to the Stage-1 extensions proposal.
An in-depth comparison is also available. The concrete differences briefly are:
const ::{ … } from …;
syntax.…::…:…
syntax.Symbol.extension
metaprogramming system.The pipe operator is a complementary proposal that can be used
to linearize deeply nested expressions like f(0, g([h()], 1), 2)
into
h() |> g(^, 1) |> f(0, ^, 2)
.
This is fundamentally different than the call-this operator’s purpose, which
would be much closer to property access .
.
It is true that property access .
, call-this, and the pipe operator all may be
used to linearize code. But this is a mere happy side effect for the first two
operators:
this
binding of a function call.In contrast, the pipe operator is designed to generally linearize all other kinds of expressions.
|>
does not improve .call’s clunkiness. Here is the clunky (and frequent)
status quo again:
fn.call(rec, arg0)
Introducing the pipe operator |>
fixes word order, but the result is even less readable. Excessive boilerplate separates the function from its receiver and arguments:
rec |> fn.call(@, arg0) // Less readable.
Only a separate operator can improve the word order without otherwise compromising readability:
rec~>fn(arg0)
The pipe champion group have been investigating whether it is possible to modify the pipe operator to address .call’s clunkiness while still addressing pipe’s other use cases (e.g., non-this-based, n-ary function calls; async function calls). It has still found none except a separate operator.
Just like how the pipe operator coexists with property access:
// Adapted from react@17.0.2/scripts/jest/jest-cli.js
Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${^}`
|> chalk.dim(^, 'node', args.join(' '))
|> console.log(^);
…so too can it work together with call-this:
// Adapted from chalk@2.4.2/index.js
return this._styles
|> (^ ? ^.concat(codes) : [codes])
|> this~>build(^, this._empty, key);
PFA (partial function application) syntax ~()
would tersely create
partially applied functions.
PFA syntax ~()
and call-this ~>
are also complementary and handle different
use cases.
For example, obj.method~()
would handle method extraction with implicit
binding, which call-this does not address. In other words, when the receiver
object itself contains the function to which we wish to bind, then we need to
repeat the receiver once, with call-this. PFA syntax would allow us to avoid
repeating the receiver.
n.on("click", v.reset.bind(v))
n.on("click", v.reset~())
In contrast, call-this changes the receiver of a function call.
receiver~>fn()
. (This unbound function might have already been extracted
from another object.) PFA syntax does not address this use case.
// bluebird@3.5.5/js/release/synchronous_inspection.js
isPending.call(this._target())
this._target()~>isPending()