Skip to content

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

@theScottyJam

Description

@theScottyJam

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.

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationhelp wantedExtra attention is neededquestionFurther information is requested

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions