Viscosity
Review
Part V · Inside real JavaScript engines · Day 17

65. Elements kinds

To JavaScript, every array element is just a value. V8 quietly sorts arrays into kinds, from “only small integers” to “anything at all”, and an array can only ever move one way.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what an elements kind is, and name the SMI, DOUBLE and ELEMENTS kinds.
  • Predict an array’s kind after a sequence of writes, using the one-way lattice.
  • Explain what a hole is and why holey arrays need extra checks.
  • Read an array’s kind from real `%DebugPrint` output.

Two arrays that look the same

To JavaScript, these hold the same kind of thing. typeof says 'number' for every element of both:

const a = [1, 2, 3];
const b = [1, 2, 3.5];

V8 sees two different arrays. Chapter 22 showed that it stores small integers as Smis and other numbers as doubles. If V8 knows an array holds only Smis, it can optimize operations on it for exactly that case[1]. One stray 3.5 changes how the whole array is stored, and what the optimizing compiler may assume.

V8 keeps elements, the values at integer indices, apart from an object’s named properties[3]. And for each array it tracks what kind of elements it holds[1].

Three kinds, one way down

Intuition

Think of a pantry with three shelves. The top one takes only identical jars, the middle one any can, the bottom one anything at all. Things only ever move down: once you’ve put a cardboard box on the bottom shelf with everything else, nobody carries the jars back up, even if the box is eaten.

There are three basic kinds. SMI means every element is a small integer. DOUBLE means every element is a number, stored as a raw 64-bit double. ELEMENTS means anything goes: each slot is an ordinary tagged value. Each new value can push the array to a more general kind. Step through one array’s life:

Step throughDown the elements-kind lattice
const a = [1, 2, 3];
a.push(4.56);
a[3] = 4;
a.push('x');
a[9] = 1;
Six elements kinds in a grid: SMI, DOUBLE and ELEMENTS down the side, PACKED and HOLEY across. Arrows point only down and to the right.PACKEDHOLEYPACKED_SMIHOLEY_SMIPACKED_DOUBLEHOLEY_DOUBLEPACKED_ELEMENTSHOLEY_ELEMENTSELEMENTS OF aFixedArray102132kind: PACKED_SMI_ELEMENTS
An array literal of small integers starts at the top: PACKED_SMI_ELEMENTS. Every slot holds a Smi, and V8 can optimize operations on the array for exactly that.
1/5

You can watch this happen. Node has a debugging function, %DebugPrint, hidden behind a flag (chapter 72 shows more of these tools). Run the first four lines of the stepper with %DebugPrint(a) after each one, and keep the lines that name the kind. Real output, Node v22.23.4 (V8 12.4):

$ node --allow-natives-syntax kinds.js \
    | grep 'elements kind'
 - elements kind: PACKED_SMI_ELEMENTS
 - elements kind: PACKED_DOUBLE_ELEMENTS
 - elements kind: PACKED_ELEMENTS
 - elements kind: HOLEY_ELEMENTS

The same output shows where the kind lives. It is part of the array’s map, the shape of chapter 63, and the change to a holey kind is listed as a map transition (addresses shortened):

 - map: 0x… <Map[32](PACKED_SMI_ELEMENTS)>
 …
   (transition to HOLEY_SMI_ELEMENTS)

It also names the backing store: a FixedArray for Smis and general values, a FixedDoubleArray once the array went DOUBLE.

Definition

An array’s elements kind records what its indexed elements can hold. The basic kinds are SMI (small integers), DOUBLE (any number, stored as untagged doubles[2]) and ELEMENTS (any value). Kinds form a lattice, and an array only ever moves to a more general kind: SMI → DOUBLE → ELEMENTS[1]. V8 records the kind in the array’s map.

Pitfall

Some numbers that look harmless are not Smis. -0, NaN and Infinity are doubles, so pushing one onto an array of small integers moves it to DOUBLE[1]. So does a large integer like 2 ** 31, which is too big for a Smi in this Node build (checked with %DebugPrint).

Holes

Every kind comes in two flavours: PACKED and HOLEY. A hole is an index below the length with nothing in it:

const xs = [0, , 2];  // a hole at 1
const ys = [];
ys[3] = 'x';          // holes at 0, 1, 2

A hole is not the same as storing undefined. Reading one makes JavaScript look further, at the object the array inherits from, Array.prototype. Usually nothing is there and you get undefined. But try this:

Try itA hole reads through to the prototype
const xs = [0, , 2];
console.log(xs.length);
console.log(Object.hasOwn(xs, 1));
console.log(xs[1]);

Array.prototype[1] = 'from proto';
console.log(xs[1]);
delete Array.prototype[1];

That is why holes are costly. For a packed array, V8 knows every index below the length has its own element. For a holey one, a read needs extra checks and may have to search the prototype chain[1].

Definition

A holey array has at least one index below its length with no element of its own. Each basic kind has a holey variant (HOLEY_SMI_ELEMENTS, HOLEY_DOUBLE_ELEMENTS, HOLEY_ELEMENTS), and PACKED → HOLEY is one-way too: filling every hole later doesn’t make the array packed again[1].

new Array(3) starts holey, because its three slots are empty. Filling them with integers leaves it HOLEY_SMI_ELEMENTS (V8 12.4 again). The post that describes the lattice adds a 2025 update: fill is now an exception to this rule[1], a change too recent for Node 22. Starting from [] and pushing never makes a hole.

Kinds meet inline caches

Elements kinds matter most to optimized code. An element load like xs[i] has an inline cache (chapter 64), and it remembers the kinds it has seen. A helper called with arrays of Smis, of doubles and of strings sees three kinds. Its loads become polymorphic and must check which case they are in every time[1]. One kind per call site keeps them monomorphic.

Under the hood

The V8 post that explains the lattice dates from 2017 and counts 21 different elements kinds; this chapter covers the six common ones[1]. Typed arrays have specialized kinds of their own[1]: %DebugPrint reports FLOAT64ELEMENTS for a Float64Array. That is one more reason Viscosity’s struct-of-arrays columns are Float64Arrays. A column’s kind is fixed: it can’t fall from SMI to DOUBLE halfway through a loop, and it can never become holey.

Your turn

ExercisePredict the elements kind

Write kindAfter(start, ops). start is the values of an array literal (no holes). ops is a list of steps: ['push', v] or ['set', i, v] (that is, a[i] = v). Return the elements kind V8 would report afterwards, as a string such as 'PACKED_SMI_ELEMENTS', 'HOLEY_DOUBLE_ELEMENTS' or 'PACKED_ELEMENTS'.

Rules: the kind only moves down the lattice SMI → DOUBLE → ELEMENTS, and once the array is holey it stays holey. A write to an index greater than the current length makes holes. isSmi is given: in Node’s 64-bit build a Smi is a 32-bit integer that isn’t -0.

function isSmi(v) {
  return Number.isInteger(v) &&
    !Object.is(v, -0) &&
    v >= -(2 ** 31) && v < 2 ** 31;
}

function kindAfter(start, ops) {
  // track the base kind, holes, length
  return 'PACKED_SMI_ELEMENTS';
}
Extra practiceThe join of two kinds

A function that sees arrays of two kinds needs code general enough for both. Write join(a, b), which takes two kind names and returns the most specific kind both can move to: the more general base, holey if either is holey. For example join('PACKED_SMI_ELEMENTS', 'HOLEY_DOUBLE_ELEMENTS') is 'HOLEY_DOUBLE_ELEMENTS'.

function join(a, b) {
  return a;
}

What you learned

  • Every array has an elements kind: SMI, DOUBLE or ELEMENTS, each PACKED or HOLEY.
  • The kinds form a lattice, and an array only moves to more general kinds. Overwriting a double with an integer doesn’t move it back.
  • -0, NaN, Infinity and large integers are doubles, not Smis.
  • A holey array has empty indices. Reading a hole can reach the prototype chain, so holey arrays need extra checks. Build arrays with literals or push.
  • The kind lives in the array’s map, and element loads’ inline caches see it: one kind per call site keeps them monomorphic.

Check your understanding

0/6
  1. 1.

    Which line moves const a = [1, 2, 3] from a SMI kind to a DOUBLE kind?

  2. 2.

    const a = [1.5, 2], then a[0] = 1. What does V8 report?

  3. 3.

    Why does V8 track packed and holey arrays separately?

  4. 4.

    Which way of building an array of three numbers ends up packed?

  5. 5.

    In %DebugPrint output, an array’s kind appears inside its map: <Map[32](PACKED_SMI_ELEMENTS)>. What does that tell you?

  6. 6.

    Viscosity stores struct-of-arrays columns as Float64Arrays. What does that mean for elements kinds?

References

  1. [1] Mathias Bynens (2017). Elements kinds in V8. V8 blog (v8.dev), 12 September 2017 (with an update note dated 28 February 2025). v8.dev/blog/elements-kinds (checked 2026-10-31)
  2. [2] The V8 project authors. src/objects/elements-kind.h (V8 source code). V8 source repository, main branch (GitHub mirror of chromium.googlesource.com/v8/v8). github.com/v8/v8/blob/main/src/objects/elements-kind.h (checked 2026-10-31)
  3. [3] Camillo Bruni (2017). Fast properties in V8. V8 blog (v8.dev), 30 August 2017. v8.dev/blog/fast-properties (checked 2026-10-31)