Series: JavaScript Iterators and Generators
Introduction Link to heading
Generators are a very particular type of function. In JavaScript, we are used to waiting for the completion of a subroutine before being able to continue with the program. In other words, each function will run until the end of its body and no other code is able to interfere, running in between.
Generators break this rule, allowing us to pause their execution and enabling a straightforward messages system to pass information in both directions.
Functions marked with the async
keyword, thanks to the await
superpower, are able to not follow the rule too because they are a direct consequence of generators.
There are plenty of uses cases for generators. They can be used to: implement iterators, implement producers, write asynchronous code in a synchronous fashion and implement both preemptive and non-preemptive multitasking, to name a few. A lot of cool projects based on them are availabe, like: redux-saga, hertzscript and tj/co.
In any case, the focus of the article will be generators from a theoretical perspective: we will talk more about what they are and how they behave accordingly to the specification than what can be done with them.
As practical examples, I’ve chosen to repropose the users
object and the fibonacciProducer
function to let you see how generators are able to make our life easier also when there is no rocket science involved.
Generators Link to heading
A generator declaration is pretty much the same as a function declaration plus the addition of a star between the function
keyword and the function name:
function * generator(...args) {
yield;
}
You are allowed to create anonymous generators, but fat arrow generators are not currently granted by the ECMAScript specification.
A generator invocation is the same as a function invocation:
generator(...);
The big syntax difference is obviously the possibility to use a new keyword inside the body of the generator: the yield keyword. It returns the control to the caller suspending the execution of the generator. The generator will literally stop on each encountered yield
waiting a signal to resume.
Like with the return
keyword, you are able to return a value each time you use the yield
keyword. Moreover, the signal that notifies the generator to resume its flow could be coupled with a value in turn.
This is the message system I mentioned earlier explained in simple terms.
The generator object Link to heading
Because of the stop & go philosophy of the generators, it is not difficult to understand why iterators were chosen for their control. Let’s see what I mean:
// a generator
function* someOddNumbers() {
yield 1;
yield 3;
yield 5;
yield 7;
return 9;
}
// we invoke it
const generatorObject = someOddNumbers();
What is that mysterious generatorObject? Why the result is not simply the value 1
? We’ll just say that when a generator function is invoked, a specific object is returned, which is both an Iterator and an Iterable. It is called generator object[1].
Pay attention to the fact that no generator code is executed at this point.
Anyway, if the result is an iterator, we should be able to call its next method. Let’s try:
generatorObject.next();
Are you able to guess the result? It’s { value:1, done:false }
.
That is because the generator will start to execute its body until a yield
keyword is encountered, where it will pause. The value 1
was coupled with the first yield
occurrence, so it will be inserted into the iterator result, with the not surprising value
key, returned by the next method invocation.
Obviously, we are still far from the generator’s body end, so the done
flag is false.
We have just learned so much thanks to these two invocations. Let’s recap the main points:
- When a generator function is called, like
someOddNumbers()
, an iterator is returned. - A generator function invocation does not mean generator execution.
- To start the execution of the code inside a generator, we have to call the next method that we can find into the iterator just taken.
- The generator’s body is executed until a
yield
keyword is encountered, where it will pause returning us the value linked to thatyield
keyword well packaged inside an iterator result.
Resuming the execution Link to heading
How to signal to the generator that it should resume its execution? We just have to call the next method again:
generatorObject.next(); // { value:3, done:false }
You can see that the generator will pause on the next yield
occurence, returning us the yielded value: 3
.
Now, let’s exhaust the iterator to better understand how the done
flag behaves:
generatorObject.next(); // { value:5, done:false }
generatorObject.next(); // { value:7, done:false }
generatorObject.next(); // { value:9, done:true }
generatorObject.next(); // { value:undefined, done:true }
If you pay attention, some of the conventions which we have discussed in the previous article are respected.
Let me show you why.
All the yielded values were returned with the purpose of sending messages to the caller. The first part of the convention number 3 is clear, so all these values are paired with done:false
.
The returned but no yielded last value (9
) falls into the convention number 4. This happens because the value is returned, so there shouldn’t be differences between that and an explicit call, which we will examine later, to the return method of the iterator.
Take that as a game rule, don’t twist your brain. So, never return a relevant value with the return keyword because it is always paired with done:true
and the second part of the convention number 3 is clear on this topic.
Remember: all the ES6 utilities tailored to suit for iterators always discard all the values that are combined with done:true
, stopping themselves at the first occurrence of it:
const generatorObject = someOddNumbers();
// 9 will be discarded
[...generatorObject]; // [1, 3, 5, 7]
The last undefined-true pair represents the out-of-bounds state, following the convention number 5.
Generators instances Link to heading
It’s worth noting that each time a generator function is called producing an iterator, an independent instance of that generator is created too, which the iterator will control. Multiple instances of the same generator will never implicitly influence one another:
const iter1 = someOddNumbers();
const iter2 = someOddNumbers();
// let's use a bit the first iterator
iter1.next(); // { value:1, done:false }
iter1.next(); // { value:3, done:false }
iter1.next(); // { value:5, done:false }
// the second iterator does handle a generator instance that is
// completely independent from the one under the control
// of the first iterator
iter2.next(); // { value:1, done:false }
The messaging system Link to heading
Let’s take a closer look at the main possibility we have to send information into the generator.
Let’s consider the following generator:
function* lazyCalculator(operator) {
const firstOperand = yield;
const secondOperand = yield;
switch(operator) {
case '+':
yield firstOperand + secondOperand;
return;
case '-':
yield firstOperand - secondOperand;
return;
case '*':
yield firstOperand * secondOperand;
return;
case '/':
yield firstOperand / secondOperand;
return;
}
}
Focus on the fact that the generator needs three inputs: an operator and two operands. I’ve chosen to set the operator during the invocation of the generator; on the contrary the operands will be provided lazily.
This is how the generator has to be used:
// I choose to perform the multiplication operation
const lazyCalculatorIterator = lazyCalculator("*");
We already know that no generator code was executed during the previous call. We have to call the next method for the first time to start it:
lazyCalculatorIterator.next(); // {value: undefined, done: false}
Let’s reasoning about that:
- why no value was provided for the first operand during the previous call?
- why we are getting
undefined
as resulting value?
Pay attention to the fact that the first next call is moving the generator towards the first yield
keyword occurrence, not from that to the second.
Therefore no value should be passed to the first next call because there is not a yield
keyword waiting for that value. That value will be silently discarded.
If you need to pass one or more values as initial settings for a generator, provide them during the generator function call as I did to set the operator.
Not even the undefined
result should surprise us because the first yield
hasn’t returned anything.
At this point, the generator instance is paused onto the first yield
keyword waiting for a value to complete the assignment to the firstOperand
.
Do not be scared by statements like: const firstOperand = yield;
. Just imagine the yield
keyword also as a placeholder for a future value, not only a weird way to return things.
You won’t be able to directly use that placeholder everywhere in an expression:
function* gen() {
const sum = yield + yield;
}
// Uncaught SyntaxError: Unexpected identifier
because of the low precedence of the yield
keyword, but you will be able to resolve the problem thanks to parenthesis:
function* gen() {
const sum = (yield) + (yield);
}
To resume the generator, let’s pass the first operand calling the next method for the second time:
lazyCalculatorIterator.next(10); // {value: undefined, done: false}
We have received back undefined
again because the second yield
keyword, on which the generator is currently paused, hasn’t returned anything too.
Now we can pass a value for the second operand:
lazyCalculatorIterator.next(2); // {value: 20, done: false}
to then receive the result of the operation we have chosen.
If we follow the generator flow, we are able to understand why.
After the third call to the next method, the generator will resume from the second yield
keyword towards the third. The latter is going to always return firstOperand [op] secondOperand
, so the mathematical expression is evaluated, to then producing a value to be returned back into the value
field of the third iterator result.
At this point, the generator is waiting for a value for the third yield
keyword, but we are able to see that the value we are going to provide won’t be actually used. Therefore, the fourth call to the next method will be performed without arguments:
lazyCalculatorIterator.next(); // {value: undefined, done: true}
After the call, the generator has encountered the return keyword, ending its execution and signalling it to us (done:true
). No value was explicitly returned, so we get undefined
again.
Let’s recap the main points:
- To send a value inside a paused generator, simply pass an argument when you call the next method to resume it.
- The argument passed to the nth next method call will be used in place of the (n-1)th
yield
. - The nth returned value will be the result of the nth next method call.
- Do not provide arguments to the first next method call because they will be discarded. Use the generator function call to pass some settings instead.
More on generators Link to heading
Iterators and Iterables Link to heading
Let’s resume the someOddNumbers
generator and the following script:
const generatorObject = someOddNumbers();
[...generatorObject]; // [1, 3, 5, 7]
This works without throwing any exceptions because, remember, each generator object is both an iterator and an iterable. It does follow the convention number 2. Therefore, when the array spread operator calls the @@iterator method of the generator object to get an iterator, it will receive the generator object itself.
Pay attention to the fact that the generator function itself is not iterable:
for (const v of someOddNumbers) {
console.log(v);
}
// TypeEror: someOddNumbers is not iterable
You have to call it to get the iterable-iterator:
for (const v of number()) {
console.log(v); // 1 3 5 7
}
Errors handling Link to heading
Generators are functions, therefore the try-catch-finally
statement can be used into them:
function* generator() {
try {
aFunctionThatMayThrow();
} catch(e) {
console.log(e);
} finally {
console.log("Finally block triggered!");
}
}
If a thrown error is not handled by any catch
block, the exception will be thrown outwards from inside the generator, ending it. The iterator that was controlling that instance of the generator will become useless:
function* generator() {
yield 1;
yield 2;
throw Error();
yield 3;
yield 4;
}
const it = generator();
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // !!! Uncaught Error !!!
it.next(); // {value: undefined, done: true}
it.next(); // {value: undefined, done: true}
Including the yield keyword Link to heading
Things become interesting when you include one or more yield
statements inside such type of block:
function* generator() {
try {
yield;
} catch(e) {
console.log(e);
} finally {
console.log("Finally block triggered!");
}
}
That leads to two logic consequences:
- The
yield
statement is able to pause thetry-catch-finally
statement, letting thecatch
be able to handle future errors. - There should be a way to insert into the generator not merely an
Error
object, but the awareness of an error condition generated by external causes.
This is where the throw method comes into play, that is implemented by each generator object. It takes an optional argument, that should be an error, throwing it into the generator in the point where it was paused.
If the respective yield
is surrounded by a try-catch
block, the generator will be able to recover from the erroneous situation, continuing its flow. Otherwise, the error will be thrown out like all the unhandled exceptions that could normally raise up from inside the generator, with the same consequences.
function* generator() {
try {
yield 1;
} catch(e) { console.log(e) }
yield 2;
yield 3;
yield 4;
yield 5;
}
const it = generator();
it.next(); // {value: 1, done: false}
// the error will be handled and printed ("Error: Handled!"),
// then the flow will continue, so we will get the
// next yielded value as result.
it.throw(Error("Handled!")); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
// now the generator instance is paused on the
// third yield that is not inside a try-catch.
// the error will be re-thrown out
it.throw(Error("Not handled!")); // !!! Uncaught Error: Not handled! !!!
// now the iterator is exhausted
it.next(); // {value: undefined, done: true}
Early return Link to heading
At any time you can trigger an early return by calling the return method of the generator object, ending it. If an argument is provided, it will be returned inside the iterator result. The iterator will become obviously exhausted:
function* generator() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
const it = generator();
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
it.return(10); // {value: 10, done: true}
it.next(); // {value: undefined, done: true}
Generator delegation Link to heading
In the same way a function is able to call a second function, a generator is able to invoke another generator. This is strongly linked with a better code organization, a proper separation of concerns, improved modularity, better maintainability and so on.
We are already able to control the resulting iterator from within the generator that has called the second generator:
function* mainGenerator() {
const innerIt = innerGenerator();
const sum = [...innerIt].reduce((a, v) => a + v, 0);
yield sum;
}
function* innerGenerator() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
const it = mainGenerator();
it.next(); // {value: 15, done: false}
The bridge Link to heading
But, how we could do to achieve a full control of the inner iterator from the outer one, to reach the highly acclaimed generator delegation?
We should be able to create a bridge that would allow us to forward all the next method calls performed on the it
iterator, with their arguments, to the innerIt
iterator, and all the yielded values from the innerGenerator
should become the results of those call.
What about the returned value of the inner generator, which could be sent by the return
keyword? It should keep a special eye on it. Moreover, the bridge should allow us to forward the throw and the return methods calls, with their respective consequences, as well as all the unhandled exceptions that may be thrown.
Last, but not least, the bridge should be able to connect with an inner version of itself, to properly handle nested generator delegation.
It turns out that this bridge was already built for us, and its name is yield*
:
function* mainGenerator() {
const innerIt = innerGenerator();
// here it is: generator delegation
yield* innerIt;
}
function* innerGenerator() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
const it = mainGenerator();
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
it.next(); // {value: 4, done: false}
it.next(); // {value: 5, done: false}
it.next(); // {value: undefined, done: true}
// now we do the same thing as before
[...mainGenerator()].reduce((a, v) => a + v, 0); // 15
We have therefore obtained complete control of the inner generator instance, giving it to the external iterator it
. But we have exposed to the client of the it
iterator some internals details which before were hidden inside the mainGenerator
generator.
But, come to think of it, that was the purpose of the bridge…
Iterables, again Link to heading
If you pay attention, you can see that in the previous example I’ve written yield* innerIt
, not yield* innerGenerator
.
That is because yield*
transfers iteration control, not generator control. Therefore you are allowed to use it with all the iterables you know, even with those built by you:
function* arrayDelegator(array) {
yield* array;
}
const it = arrayDelegator([1, 2, 3, 4, 5]);
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
it.next(); // {value: 4, done: false}
it.next(); // {value: 5, done: false}
it.next(); // {value: undefined, done: true}
Yes you guessed it: even yield*
calls the @@iterator method.
Recursive generators Link to heading
One of the main benefits of the generator delegation is that recursion with generators becomes straightforward:
function* chunkify(array, n) {
yield array.slice(0, n);
array.length > n && (yield* chunkify(array.slice(n), n));
}
[...chunkify([1, 2, 3, 4, 5, 6, 7, 8], 3)];
// [[1, 2, 3], [4, 5, 6], [7, 8]]
Don’t let this code scare you! You are able to fully understand it 😃.
Each time chunkify
is called, it yields out at most n
elements of its input array starting from the left, to then delegate the remaining part of the array to…itself!
Every time a further step of the generator recursion is performed, you are adding a new span to the bridge that connects the outermost iterator, that is the one used by the array spread operator, to the innermost one. Each yielded value, at any level, will be routed on this bridge to reach the outside.
Exceptions and the behaviour of the throw method Link to heading
During delegation, it could happen that an inner iterator is not able to handle an exception. In such a case, the exception will go back through the delegation chain until it is caught. All the generators instances crossed by the exception that was coming up, which were lacking a proper try-catch
, will be stopped one after another:
function* innerGenerator() {
yield "innerGenerator:start";
throw Error(); // <-- here the exception
yield "innerGenerator:end"; // <-- never yielded
}
function* middleGenerator() {
yield "middleGenerator:start";
yield* innerGenerator();
yield "middleGenerator:end"; // <-- never yielded
}
function* outerGenerator() {
yield "outerGenerator:start";
try {
yield* middleGenerator();
} catch {}
yield "outerGenerator:end";
}
const it = outerGenerator();
it.next(); // {value: "outerGenerator:start", done: false}
it.next(); // {value: "middleGenerator:start", done: false}
it.next(); // {value: "innerGenerator:start", done: false}
it.next(); // {value: "outerGenerator:end", done: false}
// the exception was thrown,
// both 'innerGenerator' and 'middleGenerator' weren't able to handle it
// so their execution is ended.
// The exception bubbles up to 'outerGenerator' which catches it,
// to continue its flow then.
it.next(); // {value: undefined, done: true}
Obviously, if even the outerGenerator
hadn’t been able to catch the exception, the result of calling the next method for the fourth time would have been an error because the exception would have risen to the surface and the call is not wrapped by a try-catch
block.
What if the throw method is called on the outermost iterator? Each call to the throw method will reach the innermost iterator, passing to it the error.
If the waiting yield
, on which the generator instance controlled by that iterator is paused, is wrapped by a try-catch
block, we know that the generator will be able to recover from the injected erroneous situation.
Otherwise, the exception will be thrown from the current innermost generator and we have just learned what is going to happen.
function* innerGenerator() {
try {
yield "innerGenerator:start";
} catch(e) {
yield `innerGenerator:catched ${e.message}`;
}
yield "innerGenerator:end";
}
function* middleGenerator() {
yield "middleGenerator:start";
yield* innerGenerator();
yield "middleGenerator:end"; // <-- never yielded
}
function* outerGenerator() {
yield "outerGenerator:start";
try {
yield* middleGenerator();
} catch(e) {
yield `outerGenerator:catched ${e.message}`;
}
yield "outerGenerator:end";
}
const it = outerGenerator();
it.next(); // {value: "outerGenerator:start", done: false}
it.next(); // {value: "middleGenerator:start", done: false}
it.next(); // {value: "innerGenerator:start", done: false}
it.throw(Error(42)); // {value: "innerGenerator:catched 42", done: false}
// the error was injected into 'innerGenerator'
// but the target 'yield' was wrapped inside a try-catch
it.next(); // {value: "innerGenerator:end", done: false}
it.throw(Error('foo')); // {value: "outerGenerator:catched foo", done: false}
// the error was injected into 'innerGenerator'
// but the target 'yield' wasn't wrapped inside a try-catch
// as also the 'yield*' inside the 'middleGenerator' was not.
// The exception was handled by 'outerGenerator'
it.next(); // {value: "outerGenerator:end", done: false}
it.next(); // {value: undefined, done: true}
The behaviour of the return keyword Link to heading
What about explicitly returned values?
function* innerGenerator() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6; // <-- see here
}
function* delegator() {
const res = yield* innerGenerator();
console.log(res); // 6 <-- and here
}
[...delegator()]; // [1, 2, 3, 4, 5]
We are used to the fact that values paired with done:true
are discarded by the ES6 utilities that interact with iterables, but the yield*
keyword is an exception to that, though not so huge.
Following the example of the bridge, we can say that all the values returned with the return keyword are not routed to the outside, therefore they are, to some extent, discarded. Do you see the outcome of the array spread operator in the last example? There is no 6
.
But it is also true that the yield*
needs to be replaced with an actual value when the delegation is terminated. Here is the end of the returned value!
The behaviour of the return method Link to heading
I personally consider the behaviour of the return method conceptually different from the next and the throw methods. That is because the latter ones do not influence the chained iterators directly, they do cross them to reach the innermost generator and what will happen next depends all on it.
On the contrary, the outcome of a return method call is different:
function* innerGenerator() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
function* delegator() {
console.log("yield* result: ", yield* innerGenerator());
yield 6;
}
const it = delegator();
it.next(); // {value: 1, done: false}
it.next(); // {value: 2, done: false}
it.next(); // {value: 3, done: false}
// {value: 100, done: true} with no value printed
// not {value: 6, done: false} with "yield* result: 100" printed
it.return(100);
it.next(); // {value: undefined, done: true}
You could be tempted to think that the call to the return methods during the delegation to an innerGenerator
instance would simply stop that instance with 100
as a forced return value. This whould be followed by "yield* result: 100"
printed to the console, because 100
would be the result of the delegation. After that, 6
would be returned because of the lasy yield
.
But the reality is different because a delegated method call implies subsequent calls of the method, starting from the outermost iterator, the one on which the call was originally performed, to the innermost one. This regarding the next and the throw methods has the sole consequence of data transmission when performed on generator objects.
Instead, we know that a return method call performed onto an iterator that is controlling a generator instance will set that instance as completed. This means that delegating the return method call will end not only the innermost generator instance but all the generators instances on the chain. In other words, the whole iteration will be stopped. The returned value of each return method call will be the input value for the next one in line. The last, the one returned by the innermost return method call, will be the result of the whole return operation.
Will follow another example to let you understand. Do not focus on the workaround I’ve made to show you all the values; focus on the call order and see how the returned values behave:
const innermostIterableIterator = {
[Symbol.iterator]() {
return this;
},
next() {
return {value: "innermost", done: false}
},
return(v) {
console.log(`innermostIterableIterator return method was called with ${v}`);
return {value: v+1, done: true};
}
}
const middleIterableIterator = {
*_realIteratorsFactory() {
yield* innermostIterableIterator;
yield "middleIterableIterator is still alive"; // <-- will never be printed
},
[Symbol.iterator]() {
const itToBeReturned = this._realIteratorsFactory();
const originalReturnMethod = itToBeReturned.return.bind(itToBeReturned);
itToBeReturned.return = function(v) {
console.log(`middleIterableIterator return method was called with ${v}`);
return originalReturnMethod(v+1);
}
return itToBeReturned;
}
}
function* outermostIterableIteratorGenerator() {
yield* middleIterableIterator;
yield "outermostIterableIterator is still alive"; // <-- will never be printed
}
const main = outermostIterableIteratorGenerator();
main.return = (() => {
const originalReturnMethod = main.return.bind(main);
return function(v) {
console.log(`mainIterableIterator return method was called with ${v}`);
return originalReturnMethod(v+1);
}
})();
main.next(); // {value: "innermost", done: false}
main.next(); // {value: "innermost", done: false}
main.next(); // {value: "innermost", done: false}
main.return(10);
// in order:
// 1 "mainIterableIterator return method was called with 10" is printed
// 2 "middleIterableIterator return method was called with 11" is printed
// 3 "innermostIterableIterator return method was called with 12" is printed
// 4 {value: 13, done: true} is returned
Refactoring our iterables Link to heading
In the previous article, we’ve enhanced a simple collection (users
) making it a well-formed iterable by implementing the Iterable, the Iterator and the Iterables interfaces by hand. We have also transformed a simple producer of the Fibonacci series numbers (fibonacciProducer
) into an iterators producer.
Let’s try to refactor both using generators!
users Link to heading
const users = {
james: false,
andrew: true,
alexander: false,
daisy: false,
luke: false,
clare: true,
*[Symbol.iterator]() { // this === 'users'
for (const key in this) {
if (this[key]) yield key;
}
}
}
Whoa! Only three lines! If you think about it, the needed logic is very little: yield only those keys to which a true value matches.
fibonacciProducer Link to heading
const fibonacciProducer = (function IIFE() {
function updateState({previousValue, currentValue}) {
const nextValue = previousValue + currentValue;
const newPreviousValue = currentValue;
const newCurrentValue = nextValue;
return {
newPreviousValue,
newCurrentValue
};
}
// 'fibonacciProducer' will be a generator
return function* iteratorFactory() {
// each iterator will have its own state
let previousValue = 1;
let currentValue = 0;
while (true) {
// return the current value
yield currentValue;
// then update the state
const newState = updateState({previousValue, currentValue});
currentValue = newState.newCurrentValue;
previousValue = newState.newPreviousValue;
// if we get out of bounds, stop the producer
if (currentValue > Number.MAX_SAFE_INTEGER) { return };
}
}
})();
Whoa! There is a while(true)
!
Usually, an infinite loop within a function is not a great idea, but with generators it doesn’t lead to any issue because of the yield
keyword: we are able to pause that infinite loop.
The new logic of the fibonacciProducer
isn’t so much different from the old one: each time the next method is called the current value of the series is returned back to the client, but, apart from the first iteration, a check is made first to see if the Number.MAX_SAFE_INTEGER
limit was not reached.
Conclusion Link to heading
We are at the end of the second article about Iterators and Generators. It’s been a long way, hasn’t it?
We have learnt all the nitty-gritty details about JavaScript generators: from the yield
keyword to the generator delegation!
Next article will focus on Asynchronous Iterators, a recent feature brought to us by ES2018. Why we need them? What are the differences between them and sync iterators? We will answer all these questions.
I hope to see you there again 🙂 and on twitter!
Acknowledgements Link to heading
I would like to thank Nicolò Maria Mezzopera for helping me identify a lot of grammatical errors. If you are a Vue.js developer, you need to check out his next workshop: vueandme. I also wish to thank Gabriele Di Simone, Marco Terzolo and Giovanni Riga for the time they’ve spent to check the article.
Bibliography Link to heading
- ECMAScript 2019 specification
- You Don’t Know JS series by Kyle Simpson