Many programming languages have some form of range()
function that lets you create a range of numbers. For example, PHP has a range()
function, Python has a range
type, and Ruby has Range
objects. JavaScript doesn't have any of these, but one solution is to create a range of numbers using the Array.from()
method.
/**
* Create a range of numbers.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#sequence_generator_range
* @param {number} start The first number in the range.
* @param {number} stop The last number in the range.
* @param {number} step The step between each number in the range.
* @returns {number[]} A range of numbers.
*/
function range(start, stop, step) {
return Array.from(
{ length: (stop - start) / step + 1 },
(_, i) => start + i * step
);
}
I found this range()
function in the MDN Web Docs. All I did was turn it into a function declaration (instead of a single-line arrow function expression) and document it using JSDoc. It's really clever, so I'd like to explain how it works!
Array-like objects
The Array.from()
method creates a new array from an iterable or array-like object. Iteration protocols are outside the scope of this article, but an array-like object is an object with a .length
property and indexed elements. For example, NodeList
and HTMLCollection
objects are array-like.
Why do these qualities make an object “array-like”? Because they're definitive qualities of arrays! Arrays are just a type of object and their indices are just properties. I talked about this when I explained why the Array.prototype.at()
method exists. For a quick summary, consider the following code:
const colors = ["red", "green", "blue"];
Object.getOwnPropertyDescriptors(colors);
In this case, the return value of the Object.getOwnPropertyDescriptors()
method is an object with the following structure:
{
"0": {
configurable: true,
enumerable: true,
value: "red",
writable: true
},
"1": {
configurable: true,
enumerable: true,
value: "green",
writable: true
},
"2": {
configurable: true,
enumerable: true,
value: "blue",
writable: true
},
length: {
configurable: false,
enumerable: false,
value: 3,
writable: true
}
}
This tells us that the value of the colors
constant (an array) is actually an object with four of its own properties (properties not inherited from Array.prototype
). The value of the ["0"]
property is "red"
, the value of the ["1"]
property is "green"
, and the value of the ["2"]
property is "blue"
. These correspond to the indexed elements in the array. The value of the .length
property is 3
, which reflects the number of indexed elements in the array.
Calculating the length of the array
With all that said, we can talk about the first argument to the Array.from()
method inside our range()
function. We're passing in an array-like object! It has a .length
property, although it doesn't have any indexed elements:
{ length: (stop - start) / step + 1 }
To calculate the number of elements in the new array, we're dividing the difference between the stop
and start
values by the step
value. Then we +1
because arrays are zero indexed.
For example, if we wanted an array of numbers from 1 to 10, we would invoke the range()
function with the arguments 1
, 10
, and 1
. The value of the .length
property would be calculated as (10 - 1) / 1 + 1
which is equal to 10
. This means there would be 10 elements in the new array.
Astute readers might have noticed that the value of the .length
property could be a floating-point number. For example, if we invoked the range()
function with the arguments 5
, 9
, and 3
, then the value of the .length
property would be calculated as (9 - 5) / 3 + 1
. This is approximately equal to 2.333333333333333
. It works because the value of the .length
property is normalized:
The
length
property is converted to a number, truncated to an integer, and then clamped to the range between 0 and 253 - 1.NaN
becomes0
, so even whenlength
is not present or isundefined
, it behaves as if it has value0
.
Filling the array
Because there aren't any indexed elements in our object (it only has a .length
property), it isn't truly array-like. The Array.from()
method will account for this by using the value undefined
for each of the elements in the new array.
This is where the second argument to the Array.from()
method comes in. The Array.from()
method accepts an optional map function which it will invoke on each element of the array being created.
More clearly,
Array.from(obj, mapFn, thisArg)
has the same result asArray.from(obj).map(mapFn, thisArg)
, except that it does not create an intermediate array, and mapFn only receives two arguments (element, index) without the whole array, because the array is still under construction.
We're using an anonymous arrow function expression as our map function:
(_, i) => start + i * step
The first parameter refers to the current value in the array. In our case, this will always be undefined
. Because we're not using the value, we're using an underscore (_
) to signify that it needs to be there and shouldn't be removed. This is just a convention.
The second parameter refers to the index (i
) of the current value in the array. We're returning the starting value (start
) plus the product of the index (i
) and the step
value. This makes sense if you think about it: we're starting with the start
value and adding i
number of steps. For example, if we invoked range(3, 9, 2)
, the return values would be:
3 + 0 * 2
which is equal to3
3 + 1 * 2
which is equal to5
3 + 2 * 2
which is equal to7
3 + 3 * 2
which is equal to9
Demo
Here are a few examples and a demo you can play with.
range(1, 10, 1); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
range(5, 25, 5); // [5, 10, 15, 20, 25]
range(0, 12, 3); // [0, 3, 6, 9, 12]
range(-1, -9, -1); // [-1, -2, -3, -4, -5, -6, -7, -8, -9]