Array chain methods error on non-array values which is unlike other array category methods.
_().pop(); // error
_('').pop(); // error
_().first() // undefined
This brings up another issue, should we cast array-likes to array?
_('hello').first() // 'h'
_('hello').pop() // ?
This seems to still be an issue - we encountered it after bumping our version from 1.8.3 t0 1.9.0
Edit: It looks like this was fixed in 1.9.1, if so, does this issue need to remain open?
This behaviour is still present in 1.10.2.
A slightly more convincing contrast in my option, since both involve modifying a value in place:
Object.assign(undefined, {a: 1}) // error
_().extend({a: 1}) // undefined
Array.prototype.push.call(undefined, 1) // error
_().push(1) // error
I somewhat agree this is inconsistent.
It is worth considering in what kind of real-world situation this would be most likely encountered and what to expect of the wrapped object in such cases.
In the middle of a chain, the wrapped object is predictable. For example, in the chain below, the wrapped object that is passed to push
will be either Array
or undefined
. For this reason, I would argue that the Array
wrapper methods should implement a null check. This is easy.
_.chain(something).map(f).push(x) // hoping to push x to an array
If the predicted wrapped object is more likely to be something other than an array, then it is arguably a programmer error. I don't mind throwing an exception in that case.
_.chain(something).map(f).join('').push(x) // hoping to push x to a string??
At the start of a chain, the story may seem a bit different at first sight, but I will argue that it is the same and that we should leave it at a null check. At least it must be the case that the programmer has some reason to expect something
to be a mutable array-like, since otherwise, there would be no reason to call push
:
function giveMeAMutableArraylike(something) {
_.chain(something).push(x)
}
The expectation that something
is a mutable array-like could be unmet for a variety of reasons:
giveMeAMutableArraylike
ended up being called with null
or undefined
for whatever reason. This is comparable to the first chain-in-the-middle example and can be addressed with the same null check.giveMeAMutableArraylike
do something that it obviously cannot do, i.e., breaking the contract. This is comparable to the second chain-in-the-middle example. Again, I think it is fine to throw an error in this case.something
is a bare value instead of an array with a single element, i.e., v
instead of [v]
. This is a common situation in many JavaScript APIs. If the contract of giveMeAMutableArraylike
allows this, it is obviously wrong to throw an error regardless of what v
happens to be.In the latter case, Underscore cannot know whether giveMeAMutableArraylike
allows single bare elements or not, so it is up to the programmer of giveMeAMutableArraylike
to implement its particular contract. There is nothing that a library like Underscore can do that will do the right thing for all possible contracts:
| Current behaviour | Wrap in single-element array
---|---|---
Contract allows bare single element | _Programmer needs to intervene_ | Underscore does the right thing automatically
Contract does not allow bare single element | Underscore does the right thing automatically | _Programmer needs to intervene_
Also, how do you decide whether a value should be wrapped? Some contracts might want to wrap anything that isn't an Array
, while others might want to pass arguments
and plain objects as-is. Again, this is something that a library like Underscore cannot guess on behalf of the user.
More exotic situations are conceivable as well, for example a contract that allows immutable array-like objects such as strings and that will convert them to Array
first. It is impossible to cover all the possible variations. By the principle of least change, there is just no convincing argument for supporting some contracts at the expense of contracts that were already supported. There is also a strong case for keeping the implementation as minimal as possible.
So I think the right solution is to insert just a null check and leave it at that. This is the only change to Underscore's behaviour that seems defensible in all conceivable situations and it is also consistent with the behaviour of _.extend
. A null check could still be regarded a bugfix, while doing anything more than that would quickly turn it into an arbitrary breaking change.
@jashkenas could you shine your light on this? If you agree, I will prepare a pull request that implements the null check.
@jgonggrijp — Can you elaborate in a sentence or two with the behavior that you want to implement with the null check?
Is it that array methods will explicitly throw an exception when called on a nullish value? Or that they will noop when called on a nullish value?
@jashkenas Yes. The behavior I would implement is a no-op, following the style of the Underscore array functions:
Except that the length
check wouldn't be required, so I would just insert
if (obj == null) return chainresult(this, obj);
between these two lines:
Plus tests of course, and if those tests reveal a need, maybe also a null check that turns the method into a no-op before this line:
Sounds fine to me!
Most helpful comment
This brings up another issue, should we cast array-likes to array?