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:

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 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
}

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

Share this article on:

Comment on