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
arrayfromrange - Iterating through
rangeefficiently - Checking if the
numberis in the givenrange - Checking if
arrayfromrangeincludes a specifyingnumber - Do it all in both ways:
- Using methods like
.forEach - Using
for(...)loops andinoperator
- 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
.includesand.hasis 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