A note on iterables





Good day, friends!



This note has no particular practical value. On the other hand, it explores some of the "borderline" features of JavaScript that you might find interesting.



Goggle's JavaScript Style Guide advises you to prioritize for-of whenever possible.



Airbnb's JavaScript Style Guide discourages the use of iterators. Instead of for-in and for-of loops, you should use higher-order functions such as map (), every (), filter (), find (), findIndex (), reduce (), some () to iterate over arrays and Object .keys (), Object.values ​​(), Object.entries () to iterate over arrays of objects. More on that later.



Let's go back to Google. What does “where possible” mean?



Let's look at a couple of examples.



Let's say we have an array like this:



const users = ["John", "Jane", "Bob", "Alice"];


And we want to output the values ​​of its elements to the console. How do we do this?



//  
log = (value) => console.log(value);

// for
for (let i = 0; i < users.length; i++) {
  log(users[i]); // John Jane Bob Alice
}

// for-in
for (const item in users) {
  log(users[item]);
}

// for-of
for (const item of users) {
  log(item);
}

// forEach()
users.forEach((item) => log(item));

// map()
//   -   
//       forEach()
users.map((item) => log(item));


Everything works great without any extra effort on our part.



Now, suppose we have an object like this:



const person = {
  name: "John",
  age: 30,
  job: "developer",
};


And we want to do the same.



// for
for (let i = 0; i < Object.keys(person).length; i++) {
  log(Object.values(person)[i]); // John 30 developer
}

// for-in
for (const i in person) {
  log(person[i]);
}

// for-of & Object.values()
for (const i of Object.values(person)) {
  log(i);
}

// Object.keys() & forEach()
Object.keys(person).forEach((i) => log(person[i]));

// Object.values() & forEach()
Object.values(person).forEach((i) => log(i));

// Object.entries() & forEach()
Object.entries(person).forEach((i) => log(i[1]));


See the difference? We have to resort to additional tricks, which consist in converting an object into an array in one way or another, because:



  for (const value of person) {
    log(value); // TypeError: person is not iterable
  }


What does this exception tell us? It says that the object "person", however, like any other object, is not an iterable or, as they say, an iterable (iterable) entity.



About what iterables and iterators are very well written in this section of the Modern JavaScript Tutorial. With your permission, I will not copy-paste. However, I highly recommend spending 20 minutes reading it. Otherwise, the further presentation will not make much sense to you.



Let's say that we don't like that objects are not iterable, and we want to change that. How do we do this?



Here is an example given by Ilya Kantor:



//   
const range = {
  from: 1,
  to: 5,
};

//    Symbol.iterator
range[Symbol.iterator] = function () {
  return {
    //  
    current: this.from,
    //  
    last: this.to,

    //    
    next() {
      //     
      if (this.current <= this.last) {
        //   ,    
        return { done: false, value: this.current++ };
      } else {
        //    ,      
        return { done: true };
      }
    },
  };
};

for (const num of range) log(num); // 1 2 3 4 5
// !


Essentially, the example provided is a generator created with an iterator. But back to our object. A function to turn a regular object into an iterable might look like this:



const makeIterator = (obj) => {
  //    "size",   "length" 
  Object.defineProperty(obj, "size", {
    value: Object.keys(obj).length,
  });

  obj[Symbol.iterator] = (
    i = 0,
    values = Object.values(obj)
  ) => ({
    next: () => (
      i < obj.size
        ? { done: false, value: values[i++] }
        : { done: true }
    ),
  });
};


We check:



makeIterator(person);

for (const value of person) {
  log(value); // John 30 developer
}


Happened! Now we can easily convert such an object into an array, as well as get the number of its elements through the "size" property:



const arr = Array.from(person);

log(arr); // ["John", 30, "developer"]

log(arr.size); // 3


We can simplify our function code by using a generator instead of an iterator:



const makeGenerator = (obj) => {
  //   
  //   
  Object.defineProperty(obj, "isAdult", {
    value: obj["age"] > 18,
  });

  obj[Symbol.iterator] = function* () {
    for (const i in this) {
      yield this[i];
    }
  };
};

makeGenerator(person);

for (const value of person) {
  log(value); // John 30 developer
}

const arr = [...person];

log(arr); // ["John", 30, "developer"]

log(person.isAdult); // true


Can we use the "next" method immediately after creating the iterable?



log(person.next().value); // TypeError: person.next is not a function


In order for us to have this opportunity, we must first call the Symbol.iterator of the object:



const iterablePerson = person[Symbol.iterator]();

log(iterablePerson.next()); // { value: "John", done: false }
log(iterablePerson.next().value); // 30
log(iterablePerson.next().value); // developer
log(iterablePerson.next().done); // true


It is worth noting that if you need to create an iterable object, it is better to immediately define Symbol.iterator in it. Using our object as an example:



const person = {
  name: "John",
  age: 30,
  job: "developer",

  [Symbol.iterator]: function* () {
    for (const i in this) {
      yield this[i];
    }
  },
};


Moving on. Where to go? Into metaprogramming. What if we want to get the values ​​of object properties by index, like in arrays? And what if we want certain properties of an object to be immutable. Let's implement this behavior using a proxy . Why using a proxy? Well, if only because we can:



const makeProxy = (obj, values = Object.values(obj)) =>
  new Proxy(obj, {
    get(target, key) {
      //     
      key = parseInt(key, 10);
      //    ,      0    
      if (key !== NaN && key >= 0 && key < target.size) {
        //   
        return values[key];
      } else {
        //  ,    
        throw new Error("no such property");
      }
    },
    set(target, prop, value) {
      //     "name"   "age"
      if (prop === "name" || prop === "age") {
        //  
        throw new Error(`this property can't be changed`);
      } else {
        //     
        target[prop] = value;
        return true;
      }
    },
  });

const proxyPerson = makeProxy(person);
//  
log(proxyPerson[0]); // John
//    
log(proxyPerson[2]); // Error: no such property
//   
log((proxyPerson[2] = "coding")); // true
//    
log((proxyPerson.name = "Bob")); // Error: this property can't be changed


What conclusions can we draw from all this? You can, of course, create an iterable object on your own (it's JavaScript, baby), but the question is why. We agree with the Airbnb Guide that there are more than enough native methods to solve the whole range of tasks related to iterating over keys and values ​​of objects, there is no need to “reinvent the wheel”. The guide from Google can be clarified by the fact that the for-of loop should be preferred for arrays and arrays of objects, for objects as such, you can use the for-in loop, but better - built-in functions.



I hope you found something interesting for yourself. Thank you for attention.



All Articles