New: Relative Indexing Method for Indexables

The Stage 3 relative indexing method proposal adds an at method to strings, arrays, and typed arrays (collectively, "indexables"). Let's take a look!

The Basics

In its most basic form, the at method accepts an index and gives you the item at that index:

console.log("abc".at(1));                   // "b"
console.log(["zero", "one", "two"].at(1));  // "one"

So far, it's just like using []:

console.log("abc"[1]);                      // "b"
console.log(["zero", "one", "two"][1]);     // "one"

So what's the point? When the proposal started, there were actually two points:

  1. Negative indexes!
  2. DOM collections

Sadly, though, the second motivation had to be abandoned. The original plan was for the method to be called item and to behave like the item method on NodeList and HTMLCollection. There were fairly complex reasons for that, one of which was replacing some uses of NodeList and HTMLCollection in the DOM with something called ObservedArray which is a proxy that looks and acts just like an array even though under the covers it's a DOM collection. But sadly, the name item wasn't web compatible, libraries like YUI2 and YUI3 (and others) were using item to differentiate between arrays and DOM collections. So unfortunately, that worthy goal had to be abandoned.

Let's look at the remaining motivation: Negative Indexes.

Negative Indexes

People have often wanted a last method or similar, not least for getting the last item in an array you get back from a function call, since as it stands today you have to either save the return value somewhere so you can use length:

const result = theFunction();
const last = result[result.length - 1];

...or abuse slice, creating an entire new temporary array just to get the one entry from it:

const last = theFunction().slice(-1)[0]; // Ewwwwww

There's no need for either of those with at:

const last = theFunction().at(-1);

Negative indexes are offsets from the end of the indexable, so theArray[theArray.length - 1] and theArray.at(-1) give you the same element from theArray.

As the proposal currently stands, the code above will work with all of the popular indexables: strings, arrays, and typed arrays. (There's a fourth, unpopular, indexable I'll come back to below.)

Out of Bounds Behavior

You might be wondering what happens if you go outside the indexable, for instance ["a", "b", "c"].at(-7) or "abc".at(8). The answer is: you get undefined. (In an earlier version of the proposal, going off the beginning got clamped to index 0 and going off the end worked like [], but that was changed.)

console.log(["a", "b", "c"].at(-7));    // undefined
console.log("abc".at(8));               // undefined

at's logic is (roughly):

// (Illustrative only; NOT a polyfill.)
function at(index) {
    const len = ToLength(this.length);      // (Clamps to the range 0..(2**53)-1, inclusive)
    const relativeIndex = ToInteger(index); // (Converts to number if needed, strips fractional portion off)
    const k = relativeIndex >= 0
        ? relativeIndex
        : len + relativeIndex;
    if (k < 0 || k >= len) {
        return undefined;
    }
    return this[k];
}

That's different from [], and less surprising. [] will happily go look up the property name you give it, because remember it's a property lookup, even on an array; in specification terms, arrays are objects with special treatment for properties whose names are numbers in standard form denoting the values 0 through (2**32)-2, inclusive. In the unlikely but entirely possible situation that you have a property on your array called "-4" (probably as the result of a bug), [] will give you the value of that property if you use a[-4], but at won't:

const a = ["zero", "one", "two"];
a[-4] = "negative four";
// Directly accessing the property `"-4"`:
console.log(a[-4]);         // "negative four"
// Using `at` to look for the value at index `-4` (`a.length - 7` when `a.length` is `3`):
console.log(a.at(-7));      // undefined

Similarly, if you have a property with a name that's a large positive number but not an array index (because it's greater than (2**32)-2), [] will return that property's value but at will give you undefined:

const a = ["zero", "one", "two"];
// Add a property to it with an index-looking (but invalid) name
const name = 1000000000000000000;
a[name] = "one quintillion";
console.log(a.length);      // 3, because the property isn't an array entry
// Directly accessing the property:
console.log(a[name]);       // "one quintillion"
// Using `at` to try to access the entry at index `1000000000000000000`
// (which is an invalid index)
console.log(a.at(name));    // undefined

Note that at supports the full integer index range, not just the array index range; indexes can be in the range 0 <= index < (2**53)-1. That's because although arrays are limited to a 32-bit length (so valid array indexes are 0 <= index < (2**32)-1), other indexables like strings aren't.

Making at only access within the range 0 <= index < length keeps it consistent with array methods that also ignore properties with names like "-4" and "1000000000000000000". For example, if you used map on the array in the last two examples, it would only visit three entries, and produce a new array without the "-4" or "1000000000000000000" properties.

String's at: Code Units, or Code Points?

TL;DR: it's code units, like [] and charAt.

You may remember that strings are, effectively, UTF-16 (hand-waves details; but see Chapter 10 or this blog post if you're interested). Each element in a string is a Unicode code unit. Sometimes that single code unit defines a Unicode code point (very loosely, "character"), but other times it's just half of a surrogate pair that, taken together, defines a code point. "a" is a string with a single code unit defining a code point. "😄" is a string with two code units defining a single code point (U+1F604). Some recent additions to strings have worked with code points rather than code units — most notably iteration: when you iterate through a string, you go through its code points, not its code units like you would with a for loop increasing an index by 1 each time. (More on that in Chapter 10, too.)

Given that, it's reasonable to wonder whether at on strings works in code points or code units. It works in code units, like []. I haven't seen anything specifically addressing why, but we can make a couple of educated guesses:

  • It would be really confusing if str[x] and str.at(x) gave you different results when x is in range.
  • The indexes you supply to (and get from) all the other string methods (even codePointAt) are expressed in code units, not code points.
  • It would be costly in terms of performance. Since a code point in UTF-16 might take up one or two elements in a string, in order to get the nth code point of a string, you have to start at the beginning and iterate until you've found enough code points. .at(3) and .at(300000) would have radically different performance. Making at work at the code unit level keeps it linear like [] is.

The Unpopular Indexable

Earlier I said I'd come back to a fourth unpopular indexable. It's arguments. You remember arguments, it's that automatic array-like object containing a function's arguments:

function example() {
    console.log(arguments[0]);
}
example(1); // Shows 1

arguments objects aren't arrays. They don't inherit from Array.prototype so they don't have map and filter. They don't even have slice, which all the other indexables have.

And they won't have at, either. After all, arguments is basically deprecated by rest parameters, so there's no need to use arguments in new code. The at proposal is about making the semantics/ergonomics of new code better.

There is an argument for adding it, which I made here — basically, that without it arguments is the only built-in indexable that won't have at. But I was the only person in favor, and even I was only about 65% on it. 😄 My take is that people think making arguments even weirder and more likely to bite you if you try to use it is a good thing — it'll discourage people from using it.

Wrapup

at's been at Stage 3 for several months now, but it's still possible that a new name will need to be found, that at won't be web compatible. Other details should be stable at this point, though, they haven't changed for months.

When writing this up originally in July 2020 (when it was Stage 2) I said I wouldn't be surprised to see it progress quickly, and it has. I suspect will hit Stage 4 very quickly as it's really simple to implement. It's really too bad about item not working out, but at is certainly useful without the DOM connection. I know I'll be using it. It's a nice, simple addition to the standard library.

Happy Coding!

Have a question or comment about this post? Ping me on Mastodon at @tjcrowdertech@hachyderm.io!