Until recently, the only way to count backwards from the end of a string or array was to subtract from its .length property:

const state = "Kentucky";
const y = state[state.length - 1]; // "y"

const bourbons = ["Buffalo Trace", "Maker's Mark", "Woodford Reserve"];
const makersMark = bourbons[bourbons.length - 2]; // "Maker's Mark"

With the addition of the String.prototype.at(), Array.prototype.at(), and TypedArray.prototype.at() methods, this becomes much cleaner:

const state = "Kentucky";
const y = state.at(-1); // "y"

const bourbons = ["Buffalo Trace", "Maker's Mark", "Woodford Reserve"];
const makersMark = bourbons.at(-2); // "Maker's Mark"

But why do we need these methods? Why can't we just use negative indices, like some other languages?

const y = state[-1]; // undefined

const makersMark = bourbons[-2]; // undefined

Before we get started, I'd like to thank the authors of the proposal for the .at() method for inspiring this post!

Bracket notation

The reason we can't use negative indices to count backwards is due to the design of the JavaScript language. Bracket notation isn't unique to strings and arrays; you can use it to access a property of any object:

const bourbon = {
name: "Maker's Mark",
};

const name = bourbon["name"]; // "Maker's Mark"

Dot notation is more common:

const bourbon = {
name: "Maker's Mark",
};

const name = bourbon.name; // "Maker's Mark"

But bracket notation is necessary when the property name is computed:

const bourbon = {
name: "Maker's Mark",
};

const prop = "name";

const name = bourbon[prop]; // "Maker's Mark"

Or when the property name contains a special character:

const bourbon = {
"brand-name": "Maker's Mark",
};

const brandName = bourbon["brand-name"]; // "Maker's Mark"

Property names can't be numbers; they can only be strings or symbols. In the following object literal, the property names 0, 1, and 2 are coerced into strings:

const abc = {
0: "a",
1: "b",
2: "c",
};

This is why bracket notation is necessary when accessing an element in a string or array by its index. You can't use dot notation with a number. You'll get a SyntaxError if you try:

const a = abc.0; // Uncaught SyntaxError: Unexpected number

You have to use bracket notation. Again, type coercion is occurring here to convert the number 0 to the string "0":

const a = abc[0]; // "a"

String objects and Array objects

When thinking about their indices, strings and arrays are actually types of object. They're String objects and Array objects, respectively.

The indices of strings and arrays are just properties of String objects and Array objects! We can prove this by using a for...in statement to iterate over their enumerable properties:

const state = "Kentucky";

for (const prop in state) {
console.log(prop, state[prop]);
}

// "0" "K"
// "1" "e"
// "2" "n"
// "3" "t"
// "4" "u"
// "5" "c"
// "6" "k"
// "7" "y"

const bourbons = ["Buffalo Trace", "Maker's Mark", "Woodford Reserve"];

for (const prop in bourbons) {
console.log(prop, bourbons[prop]);
}

// "0" "Buffalo Trace"
// "1" "Maker's Mark"
// "2" "Woodford Reserve"

When you try to use a negative index, it does "work", just not as you'd expect! It tries to get the value of the index as a property. The value will naturally be undefined, because we never assign a value to the property:

const state = "Kentucky";
const undefinedCharacter = state[-1]; // undefined

const bourbons = ["Buffalo Trace", "Maker's Mark", "Woodford Reserve"];
const undefinedBourbon = bourbons[-2]; // undefined

You can even set the property on an array, although you probably shouldn't:

const bourbons = ["Buffalo Trace", "Maker's Mark", "Woodford Reserve"];

bourbons[-2] = "Bulleit";

const bulleit = bourbons[-2]; // "Bulleit"

You can't set the property on a string, though. Why? Because strings are primitive values, and primitive values are immutable (they can't be changed).

As I mentioned earlier, there's a difference between primitive string values and String objects. Dan Abramov's excellent course on JavaScript mental models explains it well:

Although code like "hi".toUpperCase() makes "hi" seem like an object, this is nothing but an illusion. JavaScript creates a temporary object when you do this, and then immediately discards it. It's fine if this mechanism doesn't click for you yet. It is indeed rather confusing!

The MDN Web Docs also offer some insight. Firstly, the section about String primitives and String objects:

In contexts where a method is to be invoked on a primitive string or a property lookup occurs, JavaScript will automatically wrap the string primitive and call the method or perform the property lookup on the wrapper object instead.

Secondly, the page about primitive values:

Primitives have no methods but still behave as if they do. When properties are accessed on primitives, JavaScript auto-boxes the value into a wrapper object and accesses the property on that object instead. For example, "foo".includes("f") implicitly creates a String wrapper object and calls String.prototype.includes() on that object. This auto-boxing behavior is not observable in JavaScript code but is a good mental model of various behaviors — for example, why "mutating" primitives does not work (because str.foo = 1 is not assigning to the property foo of str itself, but to an ephemeral wrapper object).

Summary

The reason why we need the .at() methods, and why we can't use negative indices with bracket notation, is because bracket notation can be used to access a property of any object, not just String objects and Array objects. When you try to access an element in a string or array using a negative index, you're actually accessing the value of that index as a property of the String or Array object. This is usually undefined.