Range in JavaScript using es6 metaprogramming features

Read on Dev

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
Enter fullscreen mode Exit fullscreen mode

Or Scala:

(1 to 4).foreach { print } //prints: 1234
Enter fullscreen mode Exit fullscreen mode

Even Kotlin:

for (i in 1..4) print(i) //prints: 1234
Enter fullscreen mode Exit fullscreen mode

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 from range
  • Iterating through range efficiently
  • Checking if the number is in the given range
  • Checking if array from range includes a specifying number
  • Do it all in both ways:
    • Using methods like .forEach
    • Using for(...) loops and in operator

The easy peasy

Let's create a skeleton for range function:

const range = (start, end, step = 1) => {}
Enter fullscreen mode Exit fullscreen mode

Our range should have a few methods: .forEach, .map, .includes and .has

The difference between .includes and .has is that range(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 to Array.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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then use spread operator to put it outputs into array:

const map = mapFn => [...iterate(mapFn)]
Enter fullscreen mode Exit fullscreen mode

Create a factory

Adding props with Object.defineProperies seems to be apropriate way:

const rangeObj = {}

Object.defineProperties(rangeObj, {
  map,
  forEach,
  includes,
  has,
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Admire the result:

range(1, 2).forEach(console.log) // Logs: 1, 2
range(2, 5, 2).map(v => v * 10) // Outputs: [20, 40]
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And with spread we can simply create an array from the given range:

;[...range(10, 30, 10)] // Outputs: [10, 20, 30]
Enter fullscreen mode Exit fullscreen mode

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))
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
  },
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

Comment on Dev