Range in JavaScript using es6 metaprogramming features
Ranges and range like constructs are common features in programming languages. Such as Python:
for x in range(1, 4): print(x) #prints: 1, 2, 3
Or Scala:
(1 to 4).foreach { print } //prints: 1234
Even Kotlin:
for (i in 1..4) print(i) //prints: 1234
Not to mention functional languages
JavaScript doesn’t have an elegant native one range solution, neither for creating arrays
nor for iteration
only purposes, however; We’ll try to cover those issues up and get close to perfection by using es6
Symbol
and Proxy
What do you desire?
There are a few things I want to catch up:
- Creating
array
fromrange
- Iterating through
range
efficiently - Checking if the
number
is in the givenrange
- Checking if
array
fromrange
includes a specifyingnumber
- Do it all in both ways:
- Using methods like
.forEach
- Using
for(...)
loops andin
operator
- Using methods like
The easy peasy
Let’s create a skeleton for range
function:
const range = (start, end, step = 1) => {}
Our range
should have a few methods: .forEach
, .map
, .includes
and .has
The difference between
.includes
and.has
is thatrange(1, 5).has(3)
will check if 3 is between 1 and 5, and(1, 5).includes(3)
will check if array from given range, which is[1, 2, 3, 4, 5]
includes 3 similarly toArray.proptotype.includes
const range = (start, end, step = 1) => { // Helper functions: const forLoop = fn => { for (let x = start; x <= end; x += step) fn(x) } const between = (v, start, end) => v >= start && v <= end const hasValue = v => between(v, start, end) || between(v, end, start) // Functions we want to expose: const forEach = forLoop const includes = v => { for (let x = start; x <= end; x += step) { if (v === x) return true } return false } const has = hasValue }
Something is missing…
Yeah it’s a map
function. First create iterate
generator:
const iterate = function* (mapFn) { for (let x = start; x <= end; x += step) yield mapFn ? mapFn(x) : x }
Then use spread operator to put it outputs into array:
const map = mapFn => [...iterate(mapFn)]
Create a factory
Adding props with Object.defineProperies
seems to be apropriate way:
const rangeObj = {} Object.defineProperties(rangeObj, { map, forEach, includes, has, })
We should also wrap our methods with { value: method }
object to make it work:
// The wrapper function const createProp = v => ({ value: v }) // Wrap all the methods const map = createProp(mapFn => [...iterate(mapFn)]) const forEach = createProp(forLoop) const includes = createProp(v => { for (let x = start; x <= end; x += step) { if (v === x) return true } return false }) const has = createProp(hasValue)
All the code together:
const range = (start, end, step = 1) => { const forLoop = fn => { for (let x = start; x <= end; x += step) fn(x) } const between = (v, start, end) => v >= start && v <= end const hasValue = v => between(v, start, end) || between(v, end, start) const iterate = function* (mapFn) { for (let x = start; x <= end; x += step) yield mapFn ? mapFn(x) : x } const rangeObj = {} const createProp = v => ({ value: v }) const map = createProp(mapFn => [...iterate(mapFn)]) const forEach = createProp(forLoop) const includes = createProp(v => { for (let x = start; x <= end; x += step) { if (v === x) return true } return false }) const has = createProp(hasValue) Object.defineProperties(rangeObj, { map, forEach, includes, has, }) return rangeObj }
Admire the result:
range(1, 2).forEach(console.log) // Logs: 1, 2 range(2, 5, 2).map(v => v * 10) // Outputs: [20, 40] ...
The meta part
for .. range
That’s easy to accomplish. We can attach a custom iterator function to our objects, by utilizing one of the es6 features - Symbols
. There are pretty interesting, but we will focus on one of the built-in Symbols
- Symbol.iterator
. When we set the Symbol.iterator
we are replacing its behavior while calling for
loops and spread
operator:
rangeObj[Symbol.iterator] = iterate
This simple one-liner captures the point. Now if you call our range in for .. of
loop, the iterate
generator will be executed:
for (let x of range(5, 7)) console.log(x) // Logs: 5, 6, 7
And with spread
we can simply create an array
from the given range:
;[...range(10, 30, 10)] // Outputs: [10, 20, 30]
in
operator
To check if the value is in the given range with in
operator. Wa cannot use Symbol
no more. ES6
introduces another tool - Proxy
. Proxy
is used to trap calls like set
and get
to the supplied object. This way you can also trap hasProp
which corresponds to in
operator calls. That’s how it looks like:
const rangeProxy = new Proxy(rangeObj, { has(t, p) { return hasValue(parseFloat(p.toString(), 10)) }, })
The t
is a target
- our rangeObj
and the p
is a Symbol
with the value we want to verify if it’s in range. To get the number
value of Symbol
we need to first call it’s .toString
method and then parse it with parseFloat
. The output of the has
function is the output of in
expression:
3.8 in range(1, 3) // Outputs: false
A tiny problem
After implementing Proxy
you should mark, that when you try to iterate
over range it stuck on an Error
:
;[...range(2, 5, 2)] /// TypeError: Invalid attempt to spread non-iterable instance
That’s because when we call spread operator it terminates if the object has its iterator and since:
Symbol.iterator in range(1, 3) // Outputs: false
It assumes the object is non-iterable
To fix this, just type:
const rangeProxy = new Proxy(rangeObj, { has(t, p) { if (p === Symbol.iterator) return true // add this line return hasValue(parseFloat(p.toString(), 10)) }, })
And that’s it, we made it. What’s left is to give it finishing touch, like making decreasing range
- which I previously omitted for sake of simplicity:
const range = (start, end, step = 1) => { if (step <= 0) throw RangeError('Step property must be positive') if (start > end) step = -step const forLoop = fn => { if (step > 0) for (let x = start; x <= end; x += step) fn(x) else for (let x = start; x >= end; x += step) fn(x) } const between = (v, start, end) => v >= start && v <= end const hasValue = v => between(v, start, end) || between(v, end, start) const iterate = function* (mapFn) { if (step > 0) for (let x = start; x <= end; x += step) yield mapFn ? mapFn(x) : x else for (let x = start; x >= end; x += step) yield mapFn ? mapFn(x) : x } const rangeObj = {} const createProp = v => ({ value: v }) const map = createProp(mapFn => [...iterate(mapFn)]) const forEach = createProp(forLoop) const includes = createProp(v => { for (let x = start; x <= end; x += step) { if (v === x) return true } return false }) const has = createProp(hasValue) Object.defineProperties(rangeObj, { map, forEach, includes, has, }) rangeObj[Symbol.iterator] = iterate const rangeProxy = new Proxy(rangeObj, { has(t, p) { if (p === Symbol.iterator) return true return hasValue(parseFloat(p.toString(), 10)) }, }) return rangeProxy }
Caveats
You should know that es6
Proxy
and Symbol
aren’t poly-filled with tools like Babel, although Proxy::has
is covered by 93.12% browsers (by usage) and Symbol.iterator
with 93.38%. The range
is a simple example of how powerful and flexible your next library can be