New array slice notation in JavaScript - array[start:stop:step]

Last updated on March 08, 2024
New array slice notation in JavaScript - array[start:stop:step]

With this new slice notation you’ll stop writing code like this:

const decisions = [
  'maybe',
  'HELL YEAH!',
  'No.',
  'never',
  'are you fr',
  'uh, okay?',
  'never',
  'let me think about it',
];

const some = decisions.slice(1, 4);

console.log(some);
// [ 'HELL YEAH!', 'No.', 'are you fr' ]

And start writing code like this:

const decisions = [
  'maybe',
  'HELL YEAH!',
  'No.',
  'never',
  'are you fr',
  'uh, okay?',
  'never',
  'let me think about it',
];

const some = decisions[1:4];

console.log(some);
// [ 'HELL YEAH!', 'No.', 'are you fr' ]

Much shorter, readable and intuitive.

And we don’t even have to wait till it officially arrives — we can have it right now.

By extending the Array class:

Array.prototype.r = function (str) {
  const [start, end] = str.split(':').map(Number);
  return this.slice(start, end);
}
const decisions = [
  'maybe',
  'HELL YEAH!',
  'No.',
  'never',
  'are you fr',
  'uh, okay?',
  'never',
  'let me think about it',
];

const some = decisions.r('1:4');

console.log(some);
// [ 'HELL YEAH!', 'No.', 'are you fr' ]

Slice it right to the end

Will it slice to the last item if we leave out the second number?

Array.prototype.r = function (str) {
  const [start, end] = str.split(':').map(Number);
  return this.slice(start, end);
};

const yumFruits = [
  'apple🍎',
  'banana🍌',
  'orange🍊',
  'strawberry🍓',
  'mango🥭',
];

const some = yumFruits.r('1:');

console.log(some);

It doesn't?

Because end is empty string and Number('') is 0, so we have arr.slice(n, 0) which is always an empty array.

Let's upgrade r() with this new ability:

Array.prototype.r = function (str) {
  const [startStr, endStr] = str.split(':');
  // 👇 Slice from start too
  const start = startStr === '' ? 0 : Number(startStr); // ✅
  const end = endStr === '' ? this.length : Number(endStr); // ✅
  return this.slice(start, end);
};

const yumFruits = [
  'apple🍎',
  'banana🍌',
  'orange🍊',
  'strawberry🍓',
  'mango🥭',
];

console.log(yumFruits.r('1:'));
console.log(yumFruits.r(':2'));
console.log(yumFruits.r('1:3'));

Dealing with negativity

Can it handle negative indices?

const yumFruits = [
  'apple🍎',
  'banana🍌',
  'orange🍊',
  'strawberry🍓',
  'mango🥭',
];

console.log(yumFruits.r(':-2'));
console.log(yumFruits.r('2:-1'));

It surely can:

The negative start or end is passed straight to slice() which already has built-in support for them.

Start-stop-step

We upgrade again to array[start:stop:step] - step for jumping across the array in constant intervals.

Like we see in Python (again):

arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// Index 2 to 7, every 2 elements
print(arr[2:7:2])

This time slice() has no built-in stepping support, so we use a good old for loop to quickly leap through the array.

Array.prototype.r = function (str) {
  const [startStr, endStr, stepStr] = str.split(':');
  const start = startStr === '' ? 0 : Number(startStr);
  // ⚒️ negative indexes
  const absStart = start < 0 ? this.length + start : start;
  const end = endStr === '' ? this.length : Number(endStr);
  const absEnd = end < 0 ? this.length + end : end;
  const step = stepStr === '' ? 1 : Number(stepStr);
  const result = [];
  for (let i = absStart; i < absEnd; i += step) {
    result.push(this[i]);
  }
  return result;
};

const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

console.log(nums.r('2:7:2'));
console.log(nums.r('8::1'));
console.log(nums.r('-6::2'));
console.log(nums.r('::3'));

Perfect:

Array reduce() does the exact same job elegant immutably.

I think there's something about the function flow of data transformation that makes it elegant.

Readability

Array.prototype.r = function (str) {
  const [startStr, endStr, stepStr] = str.split(':');
  const start = startStr === '' ? 0 : Number(startStr);
  const absStart = start < 0 ? this.length + start : start;
  const end = endStr === '' ? this.length : Number(endStr);
  const absEnd = end < 0 ? this.length + end : end;
  const step = stepStr === '' ? 1 : Number(stepStr);
  const result = this.reduce(
    (
      acc,
      cur,
      index
    ) =>
      index >= absStart &&
      index < absEnd &&
      (index - absStart) % step === 0
        ? [...acc, cur]
        : acc,
    []
  );
  return result;
};

Flip the script

What about stepping backwards?

Of course Python has it:

arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

print(arr[7:3:-1]) # [8, 7, 6, 5]

One thing you instantly notice here is start is greater than stop. This is a requirement for backward stepping.

print(arr[3:7:-1]) # []

print(arr[7:3:1]) # []

Which makes sense: if you're counting backwards you're going from right to left so start should be more.

What do we do? Once again slice() does some of the heavy lifting for us...

We simply swap absStart and absEnd when step is negative

const [realStart, realEnd] =
    step > 0 ? [absStart, absEnd] : [absEnd, absStart];

// ❌ start > end (4:8), step: -1 -> (8:4)

// ✅ end > start (7:3), step: -1 -> (3:7)

slice() returns an empty array when end > start:

const color = [
  'cream🟡',
  'cobalt blue🔵',
  'cherry🔴',
  'celadon🟢',
];

console.log(color.slice(1, 3));
// [ 'cobalt blue🔵', 'cherry🔴' ]
console.log(color.slice(3, 0));
// []

Now let's combine everything together:

Array.prototype.r = function (str) {
  const [startStr, endStr, stepStr] = str.split(':');
  const start = startStr === '' ? 0 : Number(startStr);
  const step = stepStr === '' ? 1 : Number(stepStr);
  const absStart = start < 0 ? this.length + start : start;
  // 👇 count to start for empty end when step is negative
  const end =
    endStr === '' ? (step > 0 ? this.length : 0) : Number(endStr);
  const absEnd = end < 0 ? this.length + end : end;
  const [realStart, realEnd] =
    step > 0 ? [absStart, absEnd] : [absEnd, absStart];
  const slice = this.slice(realStart, realEnd); // 👈
  if (slice.length === 0) return []; // 👈
  const result = [];
  // 👇
  for (
    let i = absStart;
    step > 0 ? i < absEnd : i > absEnd;
    i += step
  ) {
    result.push(this[i]);
  }
  return result;
};

const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

console.log(nums.r('2:7:2'));
console.log(nums.r('-1:-7:-1'));
console.log(nums.r('-7::-1'));
console.log(nums.r('-5:9:-2'));
console.log(nums.r('::3'));

We've come a long way! Remember how we started?

Array.prototype.r = function (str) {
  const [start, end] = str.split(':').map(Number);
  return this.slice(start, end);
}

Yeah, and we didn't even add any checks for wrong types and edge cases. And it goes without saying that I spent more than a few minutes debugging this...

And just imagine how it would be if we add multi-dimensional array support like in numpy:

import numpy as np

sensor_data = np.array([
    [10, 20, 30],
    [40, 50, 60],
    [70, 80, 90]
])

temperatures = sensor_data[:, 1]

print(temperatures) # [20 50 80]

But with our new Array r() method, we've successfully brought Python's cool array slicing notation to JavaScript.

See also