Code Monkey home page Code Monkey logo

proposal-call-this's Introduction

Call-this operator for JavaScript

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.

Syntax

The syntax is being bikeshedded in issue #10.

Tentative syntax
receiver~>fn(arg0, arg1)
receiver~>ns.fn(arg0, arg1)
receiver~>(expr)(arg0, arg1)
receiver

A member expression, a call expression, an optional expression, a new expression with arguments, another call-this expression, or a parenthesized expression. The value to which this expression resolves will be bound to the right-hand side’s function object, as that function’s this receiver.

fn

A variable that must resolve to a function object.

ns

Instead of a single variable, the right-hand side may be a namespace object’s variable, followed by a chain of property identifiers. This chain must resolve to a function object.

expr

An arbitrary expression within parentheses, which must resolve to a function object.

arg0, arg1, etc.

A series of argument expressions, which may include spread ... syntax.

Description

The syntax is being bikeshedded in issue #10.

Tentative description

(A formal specification is available.)

The call-this operator ~> is a left-associative binary operator. It calls its right-hand side (a function), binding its this value to its left-hand side (a receiver value), as well well as any given arguments – in the same manner as Function.prototype.call.

For example, receiver~>fn(arg0, arg1) would be equivalent to fn.call(receiver, arg0, arg1) (except that its behavior does not change if code elsewhere reassigns the global method Function.prototype.call).

Likewise, receiver~>(createFn())(arg0, arg1) would be roughly equivalent to createFn().call(receiver, arg0, arg1).

If the operator’s right-hand side does not evaluate to a function during runtime, then the program throws a TypeError.

The operator’s left side has equal precedence with member expressions, call expressions, new expressions with arguments, and optional expressions. Like those operators, the call-this operator also may be short-circuited by optional expressions in its left-hand side.

Left-hand side Example Grouping
Member expressions obj.prop~>fn(a) (obj.prop)~>fn(a)
Call expressions obj()~>fn(a) (obj())~>fn(a)
Optional expressions obj?.prop~>fn(a) (obj?.prop)~>fn(a)
new expressions with arguments new C()~>fn(a) (new C())~>fn(a)

The operator’s right-hand side, as with decorators, may be one of the following:

  • A single identifier or private field (like fn or #field).
  • A chain of identifiers and/or private fields (like ns.fn or this.ns.#field).
  • A parenthesized expression (like (createFn())).

For example, receiver~>ns.ns.ns.fn groups as receiver~>(ns.ns.ns.fn).

Similarly to the . and ?. operators, the call-this operator may be padded by whitespace or not.
For example, receiver~>fn
is equivalent to receiver~>fn,
and receiver~>(createFn())
is equivalent to receiver~>(createFn()).

The call-this operator may be optionally chained with ?. (i.e., ?.~>):
receiver~>fn will always result in a bound function, regardless of whether receiver is nullish.
receiver ?.~>fn will result in null or undefined if receiver is null or undefined.
receiver ?.~>fn(arg) also short-circuits as usual, before arg is evaluated, if receiver is nullish.

A new expression may not contain a call-this expression without parentheses. new x~>fn() is a SyntaxError. Otherwise, new x~>fn() would be visually ambiguous between
(new x)~>fn() and new (x~>fn()).

Why a call-this operator

In short:

  1. .call is very useful and very common in JavaScript codebases.
  2. But .call is clunky and unergonomic.

.call is very common

The 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.

What methodology was used to get these results?

First, we download the 2019-06-04 pre-built Gzemnid dataset for the top-1000 downloaded NPM packages. We also need Gzemnid’s search.topcode.sh script in the same active directory, which in turn requires the lz4 command suite. search.topcode.sh will output lines of code from the top-1000 packages that match the given regular expression.

./search.topcode.sh '\.call\b' | head
grep -aE  "\.call\b"
177726827	[email protected]/src/common.js:101:					match = formatter.call(self, val);
177726827	[email protected]/src/common.js:111:			createDebug.formatArgs.call(self, args);
154772106	[email protected]/index.js:54:  type = toString.call(val);
139612972	[email protected]/errors-browser.js:26:      return _Base.call(this, getMessage(arg1, arg2, arg3)) || this;
139612972	[email protected]/lib/_stream_duplex.js:60:  Readable.call(this, options);
139612972	[email protected]/lib/_stream_duplex.js:61:  Writable.call(this, options);
139612972	[email protected]/lib/_stream_passthrough.js:34:  Transform.call(this, options);
139612972	[email protected]/lib/_stream_readable.js:183:  Stream.call(this);
139612972	[email protected]/lib/_stream_readable.js:786:  var res = Stream.prototype.on.call(this, ev, fn);

We use awk to count those matching lines of code and compare their numbers for call and several other frequently used functions.

> ls
search.topcode.sh
slim.topcode.1000.txt.lz4
> ./search.topcode.sh '\.call\b' | grep -E --invert-match '//.*\.call|/\*.+\.call|[^a-zA-Z][A-Z][a-zA-Z0-9_$]*\.call\( *this|_super\.call|_super\.prototype\.|_getPrototypeOf|_possibleConstructorReturn|__super__|WEBPACK VAR INJECTION|_objectWithoutProperties|\.hasOwnProperty\.call' | awk 'END { print NR }'
315922
> ./search.topcode.sh '\.bind\b' | awk 'END { print NR }'
170248
> ./search.topcode.sh '\b.map\b' | awk 'END { print NR }'
1016503
> ./search.topcode.sh '\bconsole.log\b' | awk 'END { print NR }'
271915
> ./search.topcode.sh '\.slice\b' | awk 'END { print NR }'
182292
> ./search.topcode.sh '\.set\b' | awk 'END { print NR }'
168872
> ./search.topcode.sh '\.push\b' | awk 'END { print NR }'
70116

Note that, for .call, we use grep to exclude several irrelevant occurrences of .call either within comments or from transpiled code. We err on the side of false exclusions.

Excluded pattern Reason
//.*\.call Code comment.
/\*.+\.call Code comment.
[^a-zA-Z][A-Z][a-zA-Z0-9_$]*\.call\( *this Constructor call obsoleted by super. See note.
_super\.call Babel-transpiled super() artifact.
_super\.prototype\. Babel-transpiled super.fn() artifact.
_objectWithoutProperties Babel-transpiled ... artifact.
_getPrototypeOf Babel artifact.
_possibleConstructorReturn Babel artifact.
__super__ CoffeeScript artifact.
WEBPACK VAR INJECTION Webpack artifact.
\.hasOwnProperty\.call Obsoleted by Object.has.

These excluded patterns were determined by an independent investigator (Scott Jamison), after manually reviewing the first 10,000 occurrences of .call in the dataset for why each occurrence occurred.

The excluded [^a-zA-Z][A-Z][a-zA-Z0-9_$]*\.call\( *this pattern deserves a special note. This pattern matches any capitalized identifier followed by .call(this. We exclude any such occurrences because any capitalized identifier likely refers to a constructor, and using .call on a constructor is a pattern that has largely been obviated by class and super syntax. It is likely that this pattern erroneously excludes many legitimate uses of .call from our count, but this bias against .call is acceptable for the purposes of rough comparison.


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 [email protected].
tryCatch(item).call(boundTo, e);

Conditionally switching a call between two methods:

const method = obj.f ?? obj.g;
method.call(obj, arg0, arg1);

// From [email protected].
// 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 [email protected].
return fs$read.call(fs, fd, /*…*/)

Protecting a method call from prototype pollution:

// From [email protected].
// 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 clunky

In 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.

// [email protected]/index.js
type = toString.call(val);
type = val~>toString();

// [email protected]/src/common.js
match = formatter.call(self, val);
match = self~>formatter(val);

createDebug.formatArgs.call(self, args);
self~>createDebug.formatArgs(args);

// [email protected]/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);

// [email protected]/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);

// [email protected]/internal/initialParams.js
var callback = args.pop(); return fn.call(this, args, callback);
var callback = args.pop(); return this~>fn(args, callback);

// [email protected]/lib/ajv.js
validate = macro.call(self, schema, parentSchema, it);
validate = self~>macro(schema, parentSchema, it);

// [email protected]/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.

Concerns about ecosystem schism

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.

  • 𝘟: Some APIs (like “functional” APIs) use non-this-based ƒs.
  • 𝘠: Some APIs (like “object-oriented” APIs) use 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 |>, 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();

Non-goals

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.

Related proposals

Old bind operator

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.)

Extensions

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:

  1. Call-this has no special variable namespace.
  2. Call-this has no implicit syntactic handling of property accessors.
  3. Call-this has no polymorphic const ::{ … } from …; syntax.
  4. Call-this has no polymorphic …::…:… syntax.
  5. Call-this has no Symbol.extension metaprogramming system.

Pipe operator

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:

  • Property access is tightly coupled to object membership.
  • Call-this is simply changes the 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 [email protected]/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 [email protected]/index.js
return this._styles
  |> (^ ? ^.concat(codes) : [codes])
  |> this~>build(^, this._empty, key);

PFA syntax

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.

// [email protected]/js/release/synchronous_inspection.js
isPending.call(this._target())
this._target()~>isPending()

proposal-call-this's People

Contributors

domenic avatar jakearchibald avatar js-choi avatar probins avatar robpalme avatar senocular avatar styfle avatar thescottyjam avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

proposal-call-this's Issues

Should we allow PrivateIdentifier after . in SimpleMemberExpression?

The current syntax

SimpleMemberExpression[Yield, Await] :
  IdentifierReference[?Yield, ?Await]
  SimpleMemberExpression[?Yield, ?Await] . IdentifierName
  ( Expression[+In, ?Yield, ?Await] )

disallows o.#p as a simple member expression, instead one would have to parenthesize it into (o.#p).

Is it intentional? From users's perspective, I would expect it suffices to replace .foo with .#foo when transitioning from a public property to a private one.

Motivation beyond defensiveness against mutation of globals?

The readme gives as the sole motivation for this proposal the desire to be defensive against mutation of Function.prototype.

I don't find this motivation nearly compelling enough to justify new syntax. If you want to run in an untrusted environment already need to be saving any other prototype methods, as the readme notes, even given this syntax:

// Our own trusted code, running before any adversary.
const { slice } = Array.prototype;

Once you're doing that, the additional overhead of saving the various Function.prototype methods is negligible. That is, code which wants to be defensive can (and indeed generally does) already write

const { bind, call } = Function.prototype;
const uncurryThis = bind.bind(call);
const slice = uncurryThis(Array.prototype.slice);

// [...]

delete Array.prototype.slice;
delete Function.prototype.call;

// [...]

slice([0, 1, 2], 1, 2); // works fine

So it seems to me that this syntax does relatively little to improve this relatively obscure use case. As such, it doesn't seem to me to even approach the bar for adding new syntax, at least not on the strength of the motivations given in the readme. Are there other motivations?

Ambiguity when `?.` in LHS?

At the Committee plenary today, @waldemarhorwat expressed concern that (from my memory, might be incorrect) a.b?.c::fn is ambiguous.

I’m not sure where the ambiguity comes from yet. a.b?.c::fn is equivalent to (a.b?.c)::fn. The specification has an OptionalChain :: OptionalChain :: SimpleMemberExpression production, and a.b?.c::fn should be:

  • OptionalExpression:
    • MemberExpression: a.b
    • OptionalChain:
      • OptionalChain:
        • ?.
        • IdentifierName: c
      • ::
      • SimpleMemberExpression: fn

But I may well be missing something.

Real-world examples from Node.js

I am seeking real-world examples of code that would be improved by a syntactic this-bind operator.

According to @ljharb and @bmeck, Node contains many methods that bind various intrinsic global methods in order to avoid being affected by external prototype pollution. @bmeck gives this example in tc39/proposal-extensions#11:

// instead of
const slice = Function.prototype.call.bind(Array.prototype.slice);

function copyArray(arr) {
  return slice(arr);
}

// The adversary’s external code.
delete Array.prototype.slice;
delete Function.prototype.call;

// Our own trusted code, running later.
// In spite of the adversary’s code, this does not throw an error.
copyArray(arr);

…which in this proposal would be improved to:

// Our own trusted code, running before any adversary.
const { slice } = Array.prototype;

function copyArray(arr) {
  return arr->slice();
}

// The adversary’s external code.
delete Array.prototype.slice;
delete Function.prototype.call;

// Our own trusted code, running later.
// In spite of the external code, this does not throw an error.
copyArray(arr);

Where in Node.js (or elsewhere) does this pattern actually occur? We need to transplant examples from them into the explainer.

Use case: Individually importable, tree-shakable methods

Please correct me if I am wrong, but my understanding of this proposal has me thinking that the real power of it is the extension function capability, as it would allow for libraries like rxjs and other functional libraries to provide free functions (that are tree shakeable) that operate on this. Additionally, it works well as a local builder pattern for a foreign object.

RxJS example:

Before

const searchResults$ = fromEvent(document.querySelector('input'), 'input').pipe(
  map(event => event.target.value),
  filter(searchText => searchText.length > 2),
  debounce(300),
  distinctUntilChanged(),
  switchMap(searchText => queryApi(searchText).pipe(retry(3))),
  share(),
)

After

const searchResults$ = fromEvent(document.querySelector('input'), 'input')
  -> map(event => event.target.value)
  -> filter(searchText => searchText.length > 2)
  -> debounce(300)
  -> distinctUntilChanged()
  -> switchMap(searchText => queryApi(searchText).pipe(retry(3)))
  -> share()

where a function like map has the signature

function map<T, R>(this: Observable<T>, project: (value: T, index: number) => R): Observable<R> {
  // ...
}

Builder example:

// imagine this class is external library code
class UrlBuilder {

   constructor(url) {
     this.url = new URL(url)
   }

   build(): string {
     return this.url.toString()
   }
   
	setHost(host: string): this {
       this.url.host = host;
       return this;
    }

}

// and our own local code provides extensions to it
function toUrlBuilder(this: string): UrlBuilder {
   return new UrlBuilder(this);
}

function addQueryParam(this: UrlBuilder, name: string, value: string): UrlBuilder {
   this.url.searchParams.append(name, value);
   return this
}

then we can use the proposed bind syntax to augment the existing library with our new function in a fluent chaining way

const url: string = "https://github.com/js-choi/proposal-bind-operator" -> toUrlBuilder() // local extension 
.setHost("google.com") // external library member
->addQueryParam("s", "TC39 bind proposal") // local extension
.build() // external library member

I feel like this addresses a number of the concerned use cases around the Hack style pipeline operator

Optional chaining form

We probably should allow a.b?.::c(), like how we allow a.b?.c() and a.b?.(). I will amend the specification later.

Suggestion: Loosen restriction on RHS of bind-this operator to allow dotted names

The current explainer expects the RHS of :: to be either:

  • an Identifier
  • a ParenthesizedExpression

This works well for existing variables like this:

import { fn } from "foo";
obj::fn

But seems to require excess ceremony when used with module namespace imports:

import * as foo from "foo";
obj::foo.fn // syntax error
obj::(foo.fn) // ok

It would be nice to support a dotted name, especially since there are some methods built into the JS core API that intentionally support tear-offs (i.e., Array.prototype.*):

obj::Array.prototype.map
obj::Array.prototype.filter

Invoking getter/setter behavior?

I was thinking about how :: is meant to be analogous to ., but realized this proposal currently only covers the "dot-call" version of the operator, foo.bar(). It leaves out the getter/setter invocation behavior of plain foo.bar. Should we consider this?

(I won't speculate on mechanics of it right now - we'd have to be slightly creative to get it to work, since there's no way to define a "free getter" like you can free function. Probably a symbol on the bar object or something could work.)

This would allow syntax like foo::bar = 1, or even chained foo::bar::baz = 1 (invoking the "bar" getter-fn with "foo", then the "baz" setter-fn on that result). This would let you ergonomically handle WeakMaps-as-Ephemerons, for example: foo::bar could translate to bar.get(foo), and foo::bar = 1 to bar.set(foo, 1).

Even if we don't want to do it right now, should we consider it as a likely future extension? This would imply, most importantly, that the RHS of the :: operator is an ident (or parenthesized expression), so things like foo::bar.baz() would parse as (foo::bar).baz(), not foo::(bar.baz)().

TypeScript implementation

Let me share my work of implementing call-this operator to TypeScript. It includes rough type check of receivers using this parameter. Any feedback is welcome.

Hope this helps the proposal to be proceed.

microsoft/TypeScript#58294

Counter proposal: Object.methodsToFunctions()

For context, I'll be heavily relying on the pipeline operator proposal here.

I know this proposal is in its very early stages, and the details aren't even finalized, but I've been trying to also think about the use case for the bind operator, and trying to see if there's perhaps a better way to solve this problem. And, I think I found an interesting solution.

Let's start by saying we have a new Object.methodToFunction() function (whether we actually add this or not can be debated, but it makes for a good stepping stone to what comes next). We can define it roughly as follows:

Object.methodToFunction = function (method) {
  // In the actual definition, it won't be suseptable to `delete Function.prototype.call` issues.
  return (thisArg, ...args) => method.call(thisArg, ...args)
}

// Usage

const map = Object.methodToFunction(Array.prototype.map)
;[2, 3]
  |> map(^, x => x + 1)
  |> console.log(^) // [3, 4]

Are we on the same page thus far? Object.methodToFunction will basically just take a method that's intended to be called with a this arg, and turn it into a function who's this arg becomes the first parameter.

OK, now let's define Object.methodsToFunctions():

Object.methodsToFunctions = function (prototype) {
  return Object.entries(prototype)
    |> ^.map(([key, value]) => [key, Object.methodToFunction(value)])
    |> Object.fromEntries(^)
}

// Usage example #1

const { map, filter } = Object.methodsToFunctions(Array.prototype)

;[2, 3]
  |> map(^, x => x + 1)
  |> filter(^, x => x % 2 === 0)
  |> console.log(^) // [4]

// Usage example #2

const $Array = Object.methodsToFunctions(Array.prototype)

;[2, 3]
  |> $Array.map(^, x => x + 1)
  |> $Array.filter(^, x => x % 2 === 0)
  |> console.log(^) // [4]

Now that's pretty nice, isn't it? It fully protects against prototype mutations, while providing people with a very simple-to-use API. I could see people wanting to use this even if they don't care about prototype mutations.

Interoperability with symbol-based protocols

In tc39/proposal-extensions#11, @bmeck wrote the following:

i think the bind operator changes the general flow and are bigger refactors from normal JS workflows and don't necessarily have the path towards dealing with protocols that [the Extensions] proposal does.

other bind proposals take an expression and bind the values within that expression. [The Extensions] proposal allows dispatch to match the normal receiver of an expression during invocation using a value not on the receiver. Other bind proposals do not allow lexical receivers in normal JS position and instead use currying.

Because this repository is meant as a simplified successor to the old bind-operator proposal and as an alternative to tc39/proposal-extensions, I opened an issue here.

Having said that, I am not quite sure I understand the quoted concerns. This proposal works with symbol-based protocols. And, in this proposal, no arguments (other than the this value) are bound.

Either of these would simply work:

y->(Array.prototype[Symbol.iterator])();
const $arrayIteratorFn = Array.prototype[Symbol.iterator];
y->$arrayIteratorFn();

CC: @ljharb

Need comprehensive review of why libraries use .call/.bind/.apply

This proposal's README states the reason we need this bind operator is because:

.bind, .call, and .apply are very useful and very common in JavaScript codebases.
But .bind, .call, and .apply are clunky and unergonomic.

It goes on to do an excellent job at explaining both of these points.

I was curious as to the reason why the .bind() family of functions was used so often, so I decided to look into each example the proposal presented to figure out why the code authors chose to use a bind function there. Here's my findings:


Update: To preserve context for what I'm talking about, here's the code snippets from the README I was originally referring to. It's comparing how current code looks like, and how it would look like if it used the proposed bind syntax.

// [email protected]/index.js
type = toString.call(val);
type = val::toString();

// [email protected]/src/common.js
match = formatter.call(self, val);
match = self::formatter(val);

createDebug.formatArgs.call(self, args);
self::(createDebug.formatArgs)(args);

// [email protected]/errors-browser.js
return _Base.call(this, getMessage(arg1, arg2, arg3)) || this;
return this::_Base(getMessage(arg1, arg2, arg3)) || this;

// [email protected]/lib/_stream_readable.js
var res = Stream.prototype.on.call(this, ev, fn);
var res = this::(Stream.prototype.on)(ev, fn);

var res = Stream.prototype.removeAllListeners.apply(this, arguments);
var res = this::(Stream.prototype.removeAllListeners)(...arguments);

// [email protected]/lib/middleware.js
Array.prototype.push.apply(globalMiddleware, callback)
globalMiddleware::(Array.prototype.push)(...callback)

// [email protected]/lib/command.js
[].push.apply(positionalKeys, parsed.aliases[key])
positionalKeys::([].push)(parsed.aliases[key])

// [email protected]/build-es5/index.js
var code = fn.apply(colorConvert, arguments);
var code = colorConvert::fn(...arguments);

// [email protected]/q.js
return value.apply(thisp, args);
return thisp::value(...args);

// [email protected]/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);

// [email protected]/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);

// [email protected]/polyfills.js
return fs$read.call(fs, fd, buffer, offset, length, position, callback)
return fs::fs$read(fd, buffer, offset, length, position, callback)

The "kind-of" package uses .call() because they want to call Object.prototype.toString() on a passed-in object, and they're following the good practice of not trusting that .toString() will be present and well-behaved on the passed-in object (e.g. the object could have a null prototype).

There were two examples of .call() from the "debug" package. In both scenarios, they're binding "this" to a custom object, in order to provide additional context for that function. In the first scenario, they're binding "this" while calling a user-provided function - this seems to be an undocumented feature. In the second scenario, they're only binding "this" to internal callbacks.

The "readable-stream"'s usage of .call() is actually pretty irrelevant to this proposal. It's the result of a babel transformation, turning class syntax with super calls into functions and prototypes, with .call() being used to help emulate super().

The "yargs" package was just trying to do what we can do today with the spread operator. The code must have been written before the spread operator existed.

It was a little difficult to follow the pretty-format's code. if anyone else wants to give it a shot, feel free. I think ultimately they were trying to do a form of method extraction from one of their third-party libraries, but they were binding the "this" value at a different location from where they were extracting the methods. From my initial impression, it looked like they might even be binding the wrong "this" value to these methods, but the third-party library didn't care, because the third-party library doesn't actually use "this" anywhere in its source code (it was a smaller library, and a grep for "this" returned zero results). I suspect I'm just misinterpreting what was going on, but maybe not.

The "Q" package uses .apply() as part of some internal logic, which seems to mainly be used by publicly exposed functions such as promise.fapply(), promise.fcall(), and promise.fbind(). Ultimately the purpose is to mimic javascript's bind functions, but with some async logic added to them.

In the case of rxjs, they're exposing functions that accept a callback argument, and an optional this-value that will be applied to the supplied callback when it's called. This is mimicking built-in functions like Array.prototype.map(), which also accept both a callback and an optional "this" parameter.

Bluebird was using .call() for two different reasons. In the first scenario, they were wanting to define similar methods on two different classes. They did so by defining them all on one class, then creating a bunch of tiny, one-line methods on the other class that basically did FirstClass.prototype.call(specialThisValue, ...args). Basically, they were using .call() for code reuse purposes. In the second scenario, .call() is being used because they expose their own promise.bind() function, which accepts a "this" value, then applies that "this" to every callback in the promise chain. Part of the reason for this behavior is to allow the end user to keep state around between callbacks by mutating "this". See here for their rational behind this feature.

graceful-fs was simply a case of method extraction. They pulled off fs.read, and instead of binding it on the spot, they supplied the correct "this" value via .call() later on.


Ok, I think that's a good sample-size of many different possible use cases for bind functions. I'm going to go ahead and categorize these use cases, so that we can discuss the value a bind syntax provides to these general categories.

Irrelevant

  • Readable-stream's use case is irrelevant to this proposal simply because it's not possible to add a new feature to improve the quality of transpiled code. By definition, transpiled code can't use new features.
  • Yargs's use case is irrelevant because that code snippet can already be greatly improved by simply using the spread operator.

Customizing the "this" value of callbacks

Both bluebird's second example and the debug package use .call() to customize the "this" value of callbacks. In general, I think a good rule of thumb to follow is "prefer providing explicit parameters over custom, implicit 'this' parameters". In other words, it's generally better to just pass a context object to a callback instead of trying to conveniently set it's "this" value to this context object.

I'm not saying it's always bad to customize the "this" value of callbacks, I'm just saying we probably shouldn't encourage this kind of behavior, which means I would be against adding a bind syntax if this was the only use case for it.

protection from global mutations

This category wasn't found in any of the examples above, but it's still worth discussing. As shown in the README, node has a lot of code that uses .bind() type functions to protect against mutation of global properties. This is a fair point, and a valid use case for this proposal, but it can't be the only driving force. As discussed here, it seems this proposal can't feed off of "global mutation protection" alone, it needs stronger reasons to exist.

Mimicking existing language semantics

Both Q and rxjs mimic existing language semantics related to this-binding. Q provides .apply()/.call()/.bind() equivalents for promises and rxjs provides a parameter to set the "this" value of a callback.

These types of features are only useful if the end user finds it useful to bind custom this values to their callbacks, which is the type of thing we're trying to discuss right now. Perhaps, there's really not a big need for these features, and the API authors didn't really need to include them. But, then again, perhaps there are good use cases, so lets keep exploring.

Language workarounds

It seems a couple of the packages were using .call() simply because the language itself didn't provide what they needed in a direct way.

  • kind-of simply needed to stringify an unknown object. This is currently not possible without .call().
  • Bluebird was using .call() for code reuse purposes - they wanted to use the logic found within one method inside a method of a different class, so they did so by simply supplying using .call() with a custom "this" value. This particular solution seems to be pretty unidiomatic - if they ever switched to class syntax and private state, they would find that they couldn't keep using this code-reuse pattern. Perhaps there's better solutions to their problems that are more idiomatic that can be done in JavaScript today, or maybe in some future version of JavaScript after features have been added to help them in their use case.

For items that fall into this category, it's probably best to analyze each situation independently and figure out what really needs to be added to the language. Sure, a this-binding shorthand could provide some minor benefits to the readability of these workarounds, but what they really need is a way to do what they're trying to do without resorting to funky workarounds. We're already actively fixing a number of problems in this category, for example, there's the mixin proposal, and there's also the recently added Object.hasOwn() function which is basically a more direct way of doing Object.prototype.hasOwnProperty.call().

Method extraction

Both pretty-format and graceful-fs are basically doing a form a method extraction, except in both cases they're choosing to supply the "this" value at the call location instead of the extract location.

This proposal stated that it doesn't wish to focus on method extraction, but ironically, I think this use case might be the most important one among the ones that have been analyzed.

Others?

Looking at the above categories of use cases, it seems that many uses of .call()/.bind()/.apply() don't actually benefit much from a this-binding syntax, however, what's been presented is not an exhaustive list of possible use cases. So I want to pose the following question:

What specific uses of .call()/.bind()/.apply() does this proposal hope to help out with? Are there use cases beyond the ones listed above that this proposal seeks to make more user-friendly? If so, what are they? This might be a good thing to eventually document in the README as well. The README explains clearly that .call() gets used everywhere, but it doesn't dig deeper to find out why it's getting used all over the place - I think those underlying use cases are the real gold mine we're trying to hit. The uses of ".call()" seems to more-often-than-not be a symptom of a deeper problem that needs addressing.

Collaborate with extension proposal

Congratulations on stage 1! 🎉

First of all, I'm strongly supportive of the bind operator things, it is a very good supplementary to the language. Both this proposal and extension proposal has a very similar motivation and similar design, similar semantics on the :: operator. @hax mentioned that the parallel namespace is not an essential part of the extension proposal and can be removed under the temperature of the committee. And the significant difference between the two proposals is the semantics of binding accessors. I'm wondering if it is better for two proposals to collaborate and find a consensus?

Bikeshedding syntax

2022-03 plenary bikeshedding slides

Possible criteria

Syntactic clarity

Would human readers often have difficulty with determining the syntax’s grouping?

Conciseness

Is the syntax significantly improve conciseness over the status quo?

Natural word order

Is the syntax’s word order more natural (e.g., subject.verb(object)) than the status quo?

Confusability with other JS features

Is there a risk of beginners and other developers confusing the syntax with regular dot property access?

Confusability with other languages

Is there a risk of developers confusing the syntax with visually similar syntaxes from other languages – especially if they have different semantics?

Overlap with other JavaScript features

Does the syntax greatly overlap with other features of the language?
(Note: A finding from the January post-plenary overflow meeting says, “In general, some overlap is okay, but too much is bad; we have to decide this on a case-by-case basis.”)

List of candidates

Receiver-first style (loose unbracketed)

This style was originally called “bind-this”, but we dropped function binding from it in 2022-03, so we renamed the style to “receiver first”.

rec :> fn(arg0)
rec ~> fn(arg0)
rec !> fn(arg0)
rec -> fn(arg0) 
rec #> fn(arg0)
rec ~~ fn(arg0)

Receiver-first style (tight bracketed)

This style was originally called “bind-this”, but we dropped function binding from it in 2022-03, so we renamed the style to “receiver first”.

rec:>fn(arg0)
rec~>fn(arg0)
rec->fn(arg0)
rec::fn(arg0)
rec:.fn(arg0)
rec-.fn(arg0)

Receiver-first style (bracketed)

rec~[fn](arg0)
rec![fn](arg0)
rec#[fn](arg0)

Function-first style

This style was originally called “call-this”, but we are now calling it “function first” to distinguish it from receiver-first call-this. See the original explainer by @tabatkins.

fn@.(rec, arg0)

This-argument style

First proposed by @rbuckton.

fn(this: rec, arg0)

Original post

@rkirsling brought up in Matrix a few days ago the reasonable concern that -> may still be confusing to beginners with ..

I would be GENUINELY scared at making every beginner worry about "was it . that I'm supposed to write? but there's also ->..."

-> is a charged symbol. It has precedent as “method call” in Perl and PHP, but this proposal is for an operator that simply “changes the receiver of a function”, which is related but different.

I’m not a huge fan of ::, since that reads as “namespacing” to me, but I plan to tentatively switch back from -> to :: before the October plenary. There’s also ~> and ~~ as possibilities. I don’t have any better ideas for its spelling right now.

Counter proposal: Function.prototype.call syntax

Ok, I'm going to take another shot at playing with alternative proposal ideas.

This is an idea I've talked about briefly on a different thread. What if, instead of having a syntax shorthand for Function.bind(), we had a syntax shorthand for Function.call?

const { map } = Array.prototype

map@([2, 3], x => x + 1)
// This is the same as this:
map.call([2, 3], x => x + 1)
// And here's how you would do the equivalent with "->" syntax:
[2, 3]->map(x => x + 1)

(I wouldn't be surprised if "@()" creates some conflicts with the decorator proposal. The exact syntax can of course be figured out later)

Why do I like Function.call syntax more than Function.bind syntax?

  • Personally, I find it hard to remember that the y in x->y is not a property on x, it's a local variable. This is a very big mental shift from how the "->" functions in a language like C++. (This probably isn't a huge deal - I'm sure I would get used to it fairly quickly, but it currently does make me pause for a moment every time I see it)
  • Being required to use parentheses with the -> syntax is a little cumbersome when the function you wish to bind is found within an object. I think this is a good design choice for ->, but it's also unfortunate because this will likely be a common scenario. Compare Array.prototype.flat@([2, 3]) to [2, 3]->(Array.prototype.flat)()

Note that we're not losing any functionality by using a Function.call() syntax. Here's how we can use the call syntax for a bind-without-call use case.

const data = [2, 3]
const { map } = Array.prototype
const { bind } = Function.prototype

// Instead of this:
const myMap = data->map

// We can do this:
const myMap = bind@(map, data)

// Or just this:
const myMap = (...args) => map@(data, ...args)

I view this call syntax as only a marginal improvement over the current proposal, but it still seems like an improvement, at least until others come and expose all of its flaws :).

Need better names for two types of method extraction

Method extraction (along with the calling of extracted methods) is a core use case of this proposal. For example, method extraction is essential for security from prototype pollution and other global mutation.

However, a non-goal of this proposal is what I currently call “tacit method extraction”: a (confusing) term I made up to refer to when a method is implicitly bound to its original owner, creating a bound function.

// “Explicit” method extraction results in a `this`-dependent ordinary function.
// This is handled by this proposal.
const { slice } = arr;
// The `this` value is supplied in the call.
arr::slice(...args);

// “Tacit” method extraction results in a `this`-independent bound function.
const slice = arr&.slice;
// The `this` value is not supplied in the call, because the function is bound.
slice(...args);

(Note that “tacit” method extraction is only useful on instance objects; it would not be useful on prototype objects, because calling a method on a prototype object is not very useful. In contrast, “explicit” method extraction is useful on prototype objects; the extracted prototype method could then be called on.)

// “Explicit” method extraction can extract from a prototype object…
const { slice } = Array.prototype;
// …and then a different `this` value may be supplied in the call.
arr::slice(...args);

I think these two concepts could use better names than “explicit” versus “tacit”. I don’t really have any other ideas, though.

(There’s even a third type of method extraction called “call-binding”, in which the this binding gets “uncurried” into a new (bound) function, using fn.call.bind, so that this is once again supplied at the call. @ljharb’s call-this call-bind library does this; Node’s primordials also use this pattern. I don’t even want to think about how they should fit in our nomenclature.)

Suggestion: ESbuild plugin

babel is cool, but slow & I've seen many new projects skip Babel & use Vite or ESBuild, both of which run much faster, saving both time & energy.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.