Array.prototype.idx method, alternative to Array.prototype.at

I think there is room for—and a solid case to be made for—an alternative to the Array.prototype.at method that would take a finite integer as its sole argument, the index value of the desired element.

Similar to Array.prototype.at, Array.prototype.idx would support negative index values, but contrastingly, it would not be syntactical sugar for arr.slice(n)[0] but rather implement a new spec that would only accept an integer as its sole argument, representing the desired index.

In the case of negative values, negative indices would start at -0 (not -1) and descend from there.

Using the array ["a", "b", "c", "d", "e"] for example…

const arr = ["a", "b", "c", "d", "e"]
arr.idx(0) // -> "a"
arr.idx(1) // -> "b"
arr.idx(-0) // -> "e"
arr.idx(-1) // -> "d"

Adding a method supporting a -0 index would be vital in equal polarity in comparison. If a function takes in an index offset value as one argument and a direction (e.g. LFT/asc, RTL/dsc), the return value would be a simple calculation of the offset multiplied by the direction where the direction would be represented as either 1 or -1 which may in practice be calculated using Math.sign.

Using Array.prototype.at, this would require several additional checks…

  • Firstly, checking to ensure the passed value is indeed a valid finite integer
  • Secondly, checking to see if the direction is RTL/dsc and if the computed value would yield a negative index value, the function would have to subtract one from the desired negative offset value.

For example, if someone desired a value with an offset of 1 from the end of the array (the second from the end), the function would have to return an offset of -1 * (1+1) which computes to -2 in order to balance the -1 offset Array.prototype.slice imposes (by nature of length - 1) and return the desired value.

Using this proposed Array.prototype.idx method however, the desired return value would always be computed as offset * direction regardless of whether the value is positive or negative.

An offset of 0 from the end of the array (RTL) would be the equivalent of [1, 2, 3].idx(-0), yielding 3.

Regarding the proposed spec, accounting for -0 values is historically possible with logic like this:

function isNeg0(n) {
    return n === 0 && Math.sign(Infinity / n) === -1
}
isNeg0(-1) // -> false
isNeg0(-0) // -> true
isNeg0(0) // -> false
isNeg0(1) // -> false

With the newer addition and now widespread support of Object.is(), detection of negative zero values is as simple as this logic:

function isNeg0(n) {
    return Object.is(n, -0)
}
isNeg0(-1) // -> false
isNeg0(-0) // -> true
isNeg0(0) // -> false
isNeg0(1) // -> false

A modern JS spec for a method like Array.prototype.idx could look as simple as a wrapper of the Array.prototype.at method, which would not deviate away from the traditional coercion method at all but rather make use of it:

Array.prototype.idx = function(index) {
    return this.at(index < 0 || Object.is(index, -0) ? index - 1 : index)
}

const arr = [1, 2, 3, 4, 5]

// positive index values
arr.idx(0) // -> 1
arr.idx(1) // -> 2
arr.idx(2) // -> 3
arr.idx(3) // -> 4
arr.idx(4) // -> 5

// negative index values
arr.idx(-0) // -> 5
arr.idx(-1) // -> 4
arr.idx(-2) // -> 3
arr.idx(-3) // -> 2
arr.idx(-4) // -> 1

// other values
arr.idx("foo") // -> 1
arr.idx(NaN) // -> 1
arr.idx(1.5) // -> 2
1 Like

Other than the very un-JS-like strict argument type checks, what use cases does this solve that the current .at can't?

I think most developers are ignorant (unaware) of negative zero, and a method that has different behaviour for the two values would be rather confusing.

1 Like

The fact that the at method coerces values seems to be a decision that the committee is not ashamed of. This is how the language's built-in API has always worked, and they don't plan on breaking this pattern now - you can see more discussion around this here. So, any idea around breaking this long-standing tradition of coercing values probably won't get very far. Yes, it's annoying and horrible, I think everyone agrees with that, including the delegates, but they understandably prefer keeping the language consistent, than trying to change course with newer APIs.

I can also see your desire for having parity with forward and backward indexing, and having them both start from zero, however, I would have to agree with @bergus on this point. There's already precedence in the language (and many other languages) that negative indexing starts at minus one. array.slice, array.copyWithin, array.indexOf, string.slice, etc, and now array.at are all following this pattern. Introducing a new function, whose main difference is that indexing starts at minus zero would be incredibly confusing, now people have to memorize which functions uses which form of negative indexing. Plus, if we're going to add a new "at" method for negative-zero indexing, shouldn't we also add new "copyWithin", "slice", "indexOf", etc methods for the same reason? And, like @bergus said, most developers probably don't realize that negative zero is different from zero - I wished negative zero did not exist, and wherever it's possible, I would like to make negative zero and zero act exactly the same.

@ljharb @theScottyJam I understand the current coercion logic and agree with how they coerce the values.

I read through the entire fix-at thread before creating this new topic. I am not advocating for changing the current coercion method. This new .idx() method would not stand to change the ways things are currently done but would offer new functionality that is currently untapped by any method.

The fix-at thread argues that .at(-1) should coerce differently but produce the same value it currently does (in most cases).

This is entirely different in that the value in this method is different from any current approach to array element retrieval by indexes. Currently, there is no "true method" method for retrieving array elements by index other than bracket notation which only works for positive integers.

.at() is close but does not work for negative index values. Instead, negative values work more similarly to the arr[arr.length - n] approach (actually arr.slice(n)[0] under the hood).

.idx() would fix the issue specifically of accepting any index value, including negative index values, on a zero-index basis. Without a method like this, implementations looking for elements from the end of the array will always have to account for an offset of -1 only from the right-side, creating an imbalance in indexing. This is how you would have to set up a function now to account for that offset:

function getIndexByOffsetDirection(arr, offset = 0, direction = 1) {
    if (
        offset == null ||
        isNaN(offset) ||
        !isFinite(offset) ||
        offset % 1 !== 0 ||
        offset < 0
    ) {
        throw new Error('The offset argument must be a positive integer >= 0')
    }
    if (![-1, 1].includes(direction)) {
        throw new Error('The direction argument must be either -1 or 1')
    }
    return arr.at(direction === 1 ? offset : offset * -1 - 1)
}

getIndexByOffsetDirection(["a", "b", "c"], 0, 1) // -> "a"
getIndexByOffsetDirection(["a", "b", "c"], 1, 1) // -> "b"
getIndexByOffsetDirection(["a", "b", "c"], 2, 1) // -> "c"
getIndexByOffsetDirection(["a", "b", "c"], 0, -1) // -> "c"
getIndexByOffsetDirection(["a", "b", "c"], 1, -1) // -> "b"
getIndexByOffsetDirection(["a", "b", "c"], 2, -1) // -> "a"

This expression arr.at(direction === 1 ? offset : offset * -1 - 1) is fully acceptable from the perspective of the typical array coercion but as the only currently available method for retrieving an array element by indexing, is also awfully limiting by requiring the developer to account for the -1 offset in negative-index cases.

Of course, the above function would likely never need to be used if there were a method available such as .idx() as .idx() would simply take in any positive or negative integer representation of the desired index, including -0 and return that element.

That is the main difference between the use-case of **.at()** and .idx(). The **.at()** method is not meant for retrieving array elements by index explicitly, but rather retrieving array elements by the traditional coercion method. This is highly valuable as well, and I would not want to change the current function of .at(), but I do see a separate and solid case to be made for .idx() for explicit retrieval of array elements by index, instead of the usual coercion.

Using .idx() instead of .at(), that line in the above function could be simplified to arr.idx(offset * direction). This would even account for cases of -0 as 0 * -1 === -0 is true is JavaScript.

With a method like .idx(), however, a function like the one above would like not even be needed, as the entire function could simply accept any positive or negative integer, like this:

["a", "b", "c"].idx(0) // -> "a"
["a", "b", "c"].idx(1) // -> "b"
["a", "b", "c"].idx(2) // -> "c"
["a", "b", "c"].idx(-0) // -> "c"
["a", "b", "c"].idx(-1) // -> "b"
["a", "b", "c"].idx(-2) // -> "a"

@bergus I do understand your concern, as many developers have not worked with -0 before, but I'd make the case that technology is constantly evolving and new methods are continually being introduced which require/prompt a developer to expand their knowledge. Another point here is that while it may seem unintuitive, -0 is actually more "true" or intuitive from an index perspective. While many developers may be ignorant/unaware of -0 as I was for many years, the truth is that -0 does exist in JavaScript as a generally equal but separate entity from its positive counterpart 0. If anything, introducing a new method like this would expand the community's awareness of -0 and create a very practical use-case for -0 where currently there are not many.


Also in response to @ljharb's concern about the "very un-JS-like strict argument type checks", I 100% agree and would not advocate for implementing the Array.prototype.idx in this fashion. My example was merely a pseudo code representation of how this code could function in use.

The primary use-case of and justification for this Array.prototype.idx would be array element retrieval by +/- index value. If the primary concern is that this deviates widely from the traditional coercion method, then I have no issue working this around the usual coercion method. Array.prototype.idx would even act as a decorator or wrapper to the Array.prototype.at method like this:

Array.prototype.idx = function(index) {
    return this.at(index < 0 || Object.is(index, -0) ? index - 1 : index)
}

["a", "b", "c"].idx(0) // -> "a"
["a", "b", "c"].idx(1) // -> "b"
["a", "b", "c"].idx(2) // -> "c"
["a", "b", "c"].idx(-0) // -> "c"
["a", "b", "c"].idx(-1) // -> "b"
["a", "b", "c"].idx(-2) // -> "a"
["a", "b", "c"].idx("foo") // -> "a"
["a", "b", "c"].idx(NaN) // -> "a"
["a", "b", "c"].idx(1.5) // -> "b"

This would work with the same coercion method traditionally used by JavaScript but with a very slim wrapper to account for the -1 offset. This is fairly common practice and would be extremely useful to save developers who have a use-case where they need to offset the -1 in some cases.

I have also updated my pseudo-code in the original topic proposal description to account for the change called out regarding the existing coercion method.

My proposal is now to implement Array.prototype.idx as a wrapper to the Array.prototype.at method which would make use of the same traditional coercion method, no deviation needed. It would essentially function the same as Array.prototype.at except that it would account for the -1 offset to allow for a more "index-zero" approach to array element retrieval.

The wrapper implementation would look like this:

Array.prototype.idx = function(index) {
    return this.at(index < 0 || Object.is(index, -0) ? index - 1 : index)
}

This would also remove the need for the "very un-JS-like strict argument type checks" @ljharb validly called out. This would be a much more JS-friendly and traditional approach to implementing a method for element retrieval by +/- index value.

I do also understand the concern about they lack of use of -0 but if we are arguing against the existence of -0 in general, then we are actually arguing over the original JavaScript. The original spec supports the coercion method which we are all advocating for using, but it also supports -0 which I agree seems awfully strange in common use, but there are very few cases you'll actually see -0 in the wild, and I find this to be a very valid use-case for its use. It's not being used arithmetically, where it would simply be used indifferentiable to a standard positive 0, but rather with Array.prototype.idx, -0 is used as a numerical representation of a negative index, not actually as a numerical value used for arithmetic calculations.

Array.prototype.at certainly has its place, and I would argue that Array.prototype.idx does as well and would easily find its way into many developers' routines for array element retrieval by explicit index value.

Now the question is, how is that one-liner so inconvenient or difficult to get right, that it needs to be in the language?

…and is it really that common?

Perhaps, could you showcase a concrete usecase of how you would use this idx function? How will this zero-based indexing help out? I'm sure there are good examples out there, but having a concrete scenario could help with the discussion.

I don't see how that function is needed at all. You can accept negative indices today, and they mean "from the end".

What you see as imbalance feels completely natural to me. I think of array indices as pointers into a range of memory. Index 0 is not just the first element, it's the start of the range whose first element is also the first element of the array. In my mind elements occupy intervals. For me example in ["A", "B", "C"], "A" occupies the interval [0; 1), "B" occupies [1; 2), and "C" occupies [2; 3). For negative indices I prepend an imaginary copy left of zero, hence interval [-1; 0) is occupied by the last element.

The amount of frustration that would generate might rival that of having two null values in the language. You'd get crazy inconsistencies whenever you do arithmetic with indices:

if (x === y) {
  assert(a.idx(x) === a.idx(y))
  // sees different elements when x is +0, y is -0
}
a.idx(pos) // last element if pos is -0
a.idx(pos + 0) // first element if pos is -0
// what if you need two consecutive items?
x = arr.idx(pos)
y = arr.idx(pos + 1) // not what you wanted if pos === -1
1 Like

I just want to mention that to me, it would make sense that arr.idx(-1) returns the final value. My reasoning would be if I wanted to get the xth term from the end in current JS, I use arr[length - x]. So the last index is [length - 1], not [length - 0].

.idx(-0) also makes sense, but you gotta admit, -0 is very unconventional. I'm saying it might be less confusing to simply not start using -0 for things.

1 Like

@lightmare @DiriectorDoc Those are all very good thoughts, and I appreciate the constructive feedback.

The Case Against Array.prototype.idx

I'd argue that we've seen more unconventional features than this get released into ES, but while I do see a strong use-case, where I might want to take in the offset integer and either 1 or -1 to determine which side to search from, I can now see several gotcha here — especially the one you mentioned @lightmare where if someone wanted to get consecutive items, determining the offset would be a real pain. Instead of being able to simply get the pos + the offset like in your example, you'd have to do something more complicated, like this:

const arr = [1, 2, 3, 4, 5],
      pos = -0,
      offset = 1,
      x = arr.idx(pos),
      y = arr.idx(Object.is(pos, -0) ? -1 * offset : pos + offset)

That said, admittedly, this proposed Array.prototype.idx method may not be the best solution to the problem I'm attacking, though I think the problem is still there. There are certainly some gotchas using -0 that render it essentially unusable (without some complicated abstractions in certain cases), yet I think there is still a strong use case for an array method that takes in an index value (positive, negative, or other accepted value) and returns an element at that same offset whether positive or negative without the imbalanced -1 offset that only applies when moving backward.

I want to be clear here. I 100% understand the purpose of the -1 offset and am not arguing against it. Rather, I am arguing for a complementary method for finding array values at an exact index-like value without needing to manually and conditionally adjust the equation with a -1 to balance the sides.

I'd like to forget my Array.prototoype.idx recommendation altogether, given the gotchas, and recommend a different approach that still solves this same "equal indices" problem— Array.prototoype.nth

A Case for Array.prototoype.nth

Not only would supporting an nth() method circumvent the gotchas of working with -0, but it would also bridge the gap of those working with the CSS :nth-child() pseudo-selector.

In an Array.prototoype.nth method, nth(-1) would still return the last value (similar to at(-1)), but nth(1) would be used to return the first item, rather than nth(0).

Here is how that would look in practice, and in a basic JS-implementation:

Implementation (making use of Array.prototype.at):

Array.prototype.nth = function(index) {
  return (
    index == 0
      ? null
      : index > 0
        ? this.at(index - 1)
        : this.at(index)
  )
}

Notes on this implementation:

This implementation supports native coercion as == 0 will match both 0 and "0", and since numeric strings automatically coerce (e.g. "1" > 0 will return true), both positive and negative non-zero indices will be coerced if present, so nth("1") will return the same value as nth(1), and likewise nth("-1") will return the same value as nth(-1).

The final this.at(index) case above accounts for negative index values and other supported argument types such as non-numeric strings or NaN, the same way that Array.prototype.at handles them.

Example Usage:

const arr = [1, 2, 3, 4, 5]
arr.nth(0) // -> null
arr.nth(1) // -> 1
arr.nth("5") // -> 5
arr.nth(-0) // -> null (no -0 funny business here)
arr.nth(-1) // -> 5
arr.nth("-5") // -> 1
arr.nth(NaN) // -> 1

And to show some of the CSS lang friendliness—

JS vs. CSS comparison for positive index values

This expression:

[...document.querySelectorAll('ul#my-list > li')].nth(3)

…targets the same element that this CSS selector would:

ul#my-list > li:nth-child(3)

JS vs. CSS comparison for negative index values

And similarly for negative nth values, this expression:

[...document.querySelectorAll('ul#my-list > li')].nth(-2)

…targets the same element that this CSS selector would:

ul#my-list > li:nth-last-child(2)
1 Like