Range in JavaScript using es6 metaprogramming features
Tags: javascript, metaprogramming, es6, webdev
Published: 2020-08-11 17:51:52

Ranges and range like constructs are common features in programming languages. Such as _Python_:

```py
for x in range(1, 4):
  print(x)
  #prints: 1, 2, 3
```

Or _Scala_:

```scala
(1 to 4).foreach { print } //prints: 1234
```

Even _Kotlin_:

```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` 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:

```js
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`

```js
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:

```js
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:

```js
const map = mapFn => [...iterate(mapFn)]
```

### Create a factory

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

```js
const rangeObj = {}

Object.defineProperties(rangeObj, {
  map,
  forEach,
  includes,
  has,
})
```

We should also wrap our methods with `{ value: method }` object to make it work:

```js
// 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:

```js
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:

```js
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:

```js
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:

```js
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:

```js
;[...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:

```js
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:

```js
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`:

```js
;[...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:

```js
Symbol.iterator in range(1, 3) // Outputs: false
```

It assumes the object is `non-iterable`
To fix this, just type:

```js
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:

```js
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%](https://caniuse.com/#feat=mdn-javascript_builtins_proxy_handler_has) browsers (by usage) and `Symbol.iterator` with [93.38%](https://caniuse.com/#feat=mdn-javascript_builtins_symbol_iterator). The `range` is a simple example of how powerful and flexible your next library can be