Engine Failure: Array is gone - can we recover?

2024-02-11

Due to a severe engine failure, our JavaScript engine has lost arrays. They are gone. With the help of historical usage of arrays, we will try to recover from this. Is it possible? Or will future generations need to live without arrays?

In our historical notes, we find the following snippet:

let list = [];
list[0] = "hello";
list[1] = "world";

list[0]; // returns "hello"
for (let i = 0; i < 2; i++) {
  console.log(list[i]);
}

Unfortunately, we can’t restore syntax, so creating arrays via bracket notation (i.e. list = []) won’t work. Let’s try this instead:

let list = {};
list[0] = "hello";
list[1] = "world";

list[0]; // returns "hello"
for (let i = 0; i < 2; i++) {
  console.log(list[i]);
}

Phew, that works. But why? A JavaScript object is a collection of key-value pairs (AKA map or dictionary) with arbitrary values; keys are always strings, though1. Everything that you put into the square brackets will be converted to a string automatically:

let list = {};
list[0] = "hello";
list["0"]; // returns "hello"
list["1"] = "world";
list[1]; // returns "world"

There’s also a shorthand for accessing properties. If the property name is a valid identifier2, you can use dot notation for reading or writing values:

let list = {};
list["score"] = 100;
list.score; // returns 100

Okay, further research reveals that there was a length property regularly used for looping over the array:

let list = []
list[0] = "hello"
list[1] = "world"
for (let i = 0; i < list.length; i++) {
  console.log(list[i])
}

Where did this come from? And who updated it? Weird. Let’s set it manually:

let list = {};
list[0] = "hello";
list[1] = "world";
list.length = 2;

for (let i = 0; i < list.length; i++) {
  console.log(list[i]);
}

That works. But can we calculate it instead? Whenever we add something to our list, we need to update it. And who has time for that? What if we, instead, calculate the length? We can iterate over the keys of an Object:

for (let key in list) {
  console.log(key);
}

This will output "0", "1", "length" and "forEach".3 In our iteration, we will first parse the key. It will either be an integer4 or NaN. We will compare it to the highest index we found so far (comparing with NaN will always return false):

for (let key in list) {
  let index = parseInt(key);
  if (index > highestIndex) {
    highestIndex = index;
  }
}

Our length will be the highest index plus 1. So let’s define it like this:

let list = {
  length: function() {
    let highestIndex = -1;

    for (let key in this) {
      let index = parseInt(key);
      if (index > highestIndex) {
        highestIndex = index;
      }
    }

    return highestIndex + 1;
  }
};

list[0] = "hello";
list[1] = "world";

for (let i = 0; i < list.length; i++) {
  console.log(list[i]);
}

Okay, length works, though we can be a bit more concise with length() { … } instead of length: function () { … }. This works, but you’d have to invoke list.length() instead of just using list.length. But JavaScript got us covered. We can define a getter:

let list = {
  get length() {
    //...
  }
}

Now we can use list.length in our for loop.

It appears people had different ways of doing things with arrays. So there was also another way of iterating over arrays:

let list = [];
list[0] = "hello";
list[1] = "world";

list.forEach(function(item, index) {
  console.log(item, index)
})

We can solve this! Let’s add a forEach function to our makeshift array:

let list = {
  forEach(fn) {
    for (let i = 0; i < this.length; i++) {
      fn(this[i], i);
    }
  },

  get length() { /* ... */ }
};

Now, our historians are a bit worried that we would need to define length and forEach on each of our lists compared to the olden days. Let’s get another tool out of our JavaScript tool belt: Prototypes! We define an Object list that we will use as a prototype for all our lists. We will add our two methods, length and forEach to it:

let list = {
  forEach(fn) {
    //...
  },

  get length() {
    //...
  }
};

Now, we can create our list like this:

let list = Object.create(list);
list[0] = "hello";
list[1] = "world";

list.forEach(function (item, i) {
  console.log(item, i);
});

This works because JavaScript looks up properties via the prototype chain: Object.create established list as the prototype of the newly generated list here.

We can also provide a constructor function to hide the weird Object.create invocation from developers working with our list. To avoid clashes in the unexpected case of our engineers recovering arrays, let’s call our replacement List for now:

function List() {
  return Object.create(list);
}

But something is wrong. We build our list with let list = List(). Our historians report that arrays were created with let list = new Array(). But worry not! new requires us to structure the code a bit differently:

function List() {}

List.prototype = {
  forEach(fn) {
    //...
  },

  get length() {
    //...
  },
};

Now it works as expected. We can be even more concise:

class List {
  forEach(fn) {
    for (let i = 0; i < this.length; i++) {
      fn(this[i], i);
    }
  }

  get length() {
    let highestIndex = -1;

    for (let key in this) {
      let index = parseInt(key);
      if (index > highestIndex) {
        highestIndex = index;
      }
    }

    return highestIndex + 1;
  }
}

At last, we’ve restored most of the core behavior of arrays from the days of yore. Creating the list is still a bit annoying, though. Our historians found that, apart from the literal syntax, there was also the option to build an array with Array.of like this:

let list = Array.of("hello", "world");

There is a mysterious object called arguments that contains all the arguments provided to a function. It is not an array, but it feels a bit like an array (like our list). We can use our old friend, the for in loop again:

class List {
	static of() {
		let target = new List();

		for (let i in arguments) {
			target[i] = arguments[i];
		}

		return target;
	}

    // ...
}

Our historians found more methods like push, pop, join, and toString that we will be able to recreate with the knowledge we have now. Maybe you want to give it a try. You can find the code at https://codeberg.org/moonglum/engine-failure. Find the test suite, and unskip the skipped tests.

It is possible. At least we thought so. But one of our historians made a startling discovery: You were able to change the length property, and it would change the content of the array. Let’s investigate that another time.

Footnotes

  1. …or symbols. But let’s ignore symbols here. 

  2. An identifier must start with $, _, or a letter, followed by $, _, letters or numbers. 

  3. If you want to learn more about the order, read the description on MDN 

  4. There are no integers in JavaScript. It will just be a number. But a number with no decimal places.