When arrays don't behave like arrays

The other day I was having difficulties while using an Excel library. The usecase was to read an Excel row and convert the fetched cells into their string representation to present to the user. The code looked something like:

const excelRow: Library.Cell[] = library.fetchRow(rowIndex);
const cleanedRow: string[] = excelRow.map(cellToString);

function cellToString(cell: Library.Cell): string;

The problem was that, in the FE, when I was accessing the array element via index to be display it, I was seeing undefined being displayed. How could that be if I always convert to a string?

I first made sure that this wasn’t a trick in the data, maybe someone wrote undefined in a cell. This was not the case, the cell was definitely empty. So I did what anyone debugging JS would do: I used JSON.stringify in the excelRow and in cleanedRow (before and after). I saw something like this:

excelRow: [
  { type: "string", value: "Data" },
  null,
  { type: "string", value: "DE" },
]

cleanedRow: [
  "Data",
  null,
  "DE"
]

I’m not making any type assertions in the cellToString method so if the input type is correct, the Typescript compiler should guarantee that everything is converted to string.

I checked the Cell type, it has null as a possible value and I was handling it directly in my logic. In desperation, I made the method disregard whatever value in the cell was present and always return a dummy value:

function cellToString(cell: Library.Cell): string {
  return "DummyValue";
}

It didn’t work. I was still seeing undefined when displaying things. Why was this element not being converted?

Confused

At this point I was a bit lost. I then noticed a few things that led me to the answer:

  1. I was getting null in my debug messages, but finding undefined when it was being displayed.
  2. When I added a debug message to the conversion function (cellToString), I was not seeing any null as input ever.
  3. The amount of calls to the conversion function (cellToString) was not nearly as many as the number of cells in the row.

This made me sure of one thing: the cellToString function is working, the issue is that the empty cells are being skipped. And for some reason these skipped cells show as null in JSON.stringify but as undefined if accessing via an index.

Enter Sparse Arrays

As it turns out, arrays in JavaScript come in two variants: dense and sparse. The Excel library I was As it turns out, arrays in JavaScript come in two variants: dense 1 and sparse. The Excel library I was using was returning sparse arrays when reading a row. But what are they? Code makes it easy to understand:

const denseArray = [1, undefined, 3];
const sparseArray = [1, , 3];

denseArray.length === sparseArray.length; // true
JSON.stringify(denseArray) === JSON.stringify(sparseArray); // true
denseArray[1] === sparseArray[1]; // true

console.log(sparseArray); // [1, empty, 3]
console.log(denseArray); // [1, undefined, 3]

const mappedDense = denseArray.map((_e) => 4); // [4, 4, 4]
const mappedSparse = sparseArray.map((_e) => 4); // [4, empty, 4]

Sparse arrays have actual holes in them for when elements are missing. This is not the same as the element itself being undefined or null, this is a special case where the element itself is not present. The quirk here is that iteration methods like .map or .forEach completely skip these elements. Accessing them via index gives us undefined, but this one actually means that the element is missing, not that the value of the element is undefined.

The Fix

The fix is simple: convert to a proper dense array. The most straightforward way is with Array.from().

const excelRow: Library.Cell[] = Array.from(library.fetchRow(rowIndex));

As it turns out, I would have easily understood the issue if I didn’t have a debug alias that always wraps my variables with JSON.stringify, console.log clearly shows empty when the index is missing.

As a final note, beware of using the Array constructor to build a X element array without it being initialised, this creates sparse arrays so might not behave as you expect it to. Deleting an element using delete method does the same thing.


  1. ‘Dense array’ isn’t an official ECMAScript term, but it’s commonly used. ↩︎