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 aString
wrapper object and callsString.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 (becausestr.foo = 1
is not assigning to the propertyfoo
ofstr
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
.