It is part of a series about Symbols, Iterables, Iterators and Generators in JavaScript, and a direct follow up of a previous post: Iteration Protocol And Configuring The TypeScript Compiler which was about the necessary compiler configurations needed to be able to work with iterables and iterators from TypeScript.
This post will then take things from there, and show how to add types to Iterables and Iterators in other to get compile time checking working.
Interfaces for Iteration Protocol
To allow for the compile time checks, TypeScript provides a couple of interfaces that capture the structures required for iterables and iterators. These interface can then be implemented at development time to ensure that the requirements are adhered to.The interfaces are:
- Iterable Interface
- Iterator Interface
- IteratorResult Interface
- IterableIterator Interface
Setting things up
The TypeScript files that would be compiled in this post is assumed to be in a directory named playground. In the same directory, we would also have the tsconfig.json file, which contains the following configuration:
{ "compilerOptions": { "module": "commonjs", "target": "es6", } }
If you do not understand what is going on in the above configuration, then take a pause and first read Iteration Protocol And Configuring The TypeScript Compiler
The only new thing in the configuration is the module settings, which we set to commonjs. This is to ensure that the code would be compiled down to JavaScript code that makes use of the commonjs module system. This option will allow us to be able to run the compiled code using node directly from the terminal without having to worry about module bundlers or loaders.
If you not sure what modules, commonjs etc mean above, do check out Understanding JavaScript Modules As A TypeScript User
Finally, to compile the Typescript files within the playground directory, we run the following command.
// note, this is run from the playground directory. tsc -p tsconfig.json
To motivate the reason why we might want the compile time guarantees that TypeScript provides for working with Iteration protocol, we start by defining a class that does not adhere to the iteration protocol and attempt to use it with the for...of syntax (which expects adherence to the iteration protocol).
In main.js, we define such a class, and attempt to use the class in a for of syntax:
class MakeRange { constructor(first, last) { this._first = first; this._last = last; } } // usage of MakeRange in a for of syntax for (let item of new MakeRange(0,10)) { console.log(item); }
If we attempt to run this code from the terminal by typing:
node main.js
We would be greeted with the following runtime error:
/Users/playground/main.js:8 for (let item of new MakeRange(0,10)) { ^ TypeError: (intermediate value) is not a function or its return value is not iterable at Object.(/Users/daderemi/Desktop/delete/playground/main.js:8:18) at Module._compile (internal/modules/cjs/loader.js:688:30) at Object.Module._extensions..js (internal/modules/cjs/loader.js:699:10) at Module.load (internal/modules/cjs/loader.js:598:32) at tryModuleLoad (internal/modules/cjs/loader.js:537:12) at Function.Module._load (internal/modules/cjs/loader.js:529:3) at Function.Module.runMain (internal/modules/cjs/loader.js:741:12) at startup (internal/bootstrap/node.js:285:19) at bootstrapNodeJSCore (internal/bootstrap/node.js:739:3)
Which is basically telling us we are attempting to use what is not an Iterable with a for of syntax.
This is the kind of omission we would want to be notified of, at compile time and not at runtime.
To get things off the ground, we start by looking at the iterable interface:
Iterable Interface
According to the Iteration protocol, an Iterable should have a method named [Symbol.iterator], that when called returns an Iterator.To learn more about this, see Iterables and Iterators in JavaScript.
This contract is encoded in the Iterable Interface which looks like this:
interface Iterable<T> { [Symbol.iterator](): Iterator<T>; }
So let us update our MakeRange class and have it implement this interface. We also update to TypeScript syntax in the process, changing from main.js to main.ts. The updated file looks like:
// within main.ts export class MakeRange implements Iterable<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } } for (let item of new MakeRange(0,10)) { console.log(item) }
In Microsoft Code, it looks like this:
Which is telling us that we need to implement the contract of the Iterable interface.
Attempting to compile the main.ts file from the terminal, would fail with a similar error message:
main.ts:1:7 - error TS2420: Class 'MakeRange' incorrectly implements interface 'Iterable'. Property '[Symbol.iterator]' is missing in type 'MakeRange'. 1 class MakeRange implements Iterable { ~~~~~~~~~ main.ts:12:18 - error TS2488: Type 'MakeRange' must have a '[Symbol.iterator]()' method that returns an iterator. 12 for (let item of new MakeRange(0,10)) {
Hence, by implementing the interface, the compiler is telling us we need to adhere to the iteration protocol of having a Symbol.iterator that returns an iterator.
Since we do not have an Iterator yet, we still go ahead and implement the Symbol.iterator method, but have it throw an error in the meantime. The updated code will now look like this:
export class MakeRange implements Iterable<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } [Symbol.iterator](): Iterator<number> { throw new Error("Method not implemented."); } } for (let item of new MakeRange(0,10)) { console.log(item) }
If we now attempt to compile, the compilation will succeed and a main.js file will be generated. But if we run the main.js, we get a runtime error:
/Users/playground/main.js:7 throw new Error("Method not implemented."); ^ Error: Method not implemented. at MakeRange.[Symbol.iterator] (/Users/daderemi/Desktop/delete/playground/main.js:7:15) at Object.<anonymous> (/Users/daderemi/Desktop/delete/playground/main.js:10:18) at Module._compile (internal/modules/cjs/loader.js:688:30) at Object.Module._extensions..js (internal/modules/cjs/loader.js:699:10) at Module.load (internal/modules/cjs/loader.js:598:32) at tryModuleLoad (internal/modules/cjs/loader.js:537:12) at Function.Module._load (internal/modules/cjs/loader.js:529:3) at Function.Module.runMain (internal/modules/cjs/loader.js:741:12) at startup (internal/bootstrap/node.js:285:19) at bootstrapNodeJSCore (internal/bootstrap/node.js:739:3)
Which clearly says we need to have a proper implementation for the [Symbol.iterator] method.
Since a proper implementation of this method requires an Iterator to be returned, we then look at the Iterator Interface next.
Iterator Interface
According to the Iteration protocol, an Interface must have a next method, that when called, returns an object that should have a value and done property.To learn more about this, see Iterables and Iterators in JavaScript.
This requirement is encoded by TypeScript in the Iterator Interface, which looks like this:
interface Iterator<T> { next(value?: any): IteratorResult<T>; return?(value?: any): IteratorResult<T>; throw?(e?: any): IteratorResult<T>; }
This interface says that for something to be an iterator, it must have a next method, with optional argument, that returns a value that adheres to the IteratorResult interface. The return and throw methods could also be implemented, but those are optional and their presence are not required to have an Iterator.
Let us then go ahead and create an Iterator for our MakeRange class. We do this in a seperate file we will call rangeIterator.ts
// within rangeIterator.ts export class RangeIterator implements Iterator<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } next(value?: any): IteratorResult<number> { throw new Error("Method not implemented."); } }
As can be seen above, the next method is not properly implemented yet. Its implementation now only throws an exception instead of returning a value that is a IteratorResult.
To be able to do that, let us look at the IteratorResult interface next.
IteratorResult Interface
According to the Iteration protocol, the value returned from calling the next method on an Iterator must have at least two properties: done and value, where done property is used to indicate if the iteration has reached its end, and the value property is the value that is being returned on each iteration. ie:{ done: false | true // Iteration done or not value: T // value of iteration. May not be present when done is true }
To learn more about this, see Iterables and Iterators in JavaScript.
This is encoded in TypeScript by the IteratorResult interface, which looks like this:
interface IteratorResult<T> { done: boolean; value: T; }
Let us have another TypeScript file name rangeResult.ts that will contain the implementation:
// within rangeResult.ts export class RangeResult implements IteratorResult<number> { done: boolean; value: number; constructor(done, value) { this.done = done; this.value = value; } }
With this in place, we can now go back to the rangeIterator.ts file and update the implementation of the next method on RangeIterator with a proper implementation which may look thus:
// within rangeIterator.ts export class RangeIterator implements Iterator<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } next(value?: any): IteratorResult<number> { if (this._first < this._last) { return new RangeResult(this._first++, false) } else { return new RangeResult(undefined, true) } } }
With the RangeIterator now properly implemented, we can also go back to the main.ts file and update the implementation of the Symbol.iterator on the MakeRange class to return a proper Iterator instead of throwing error.
Thee updated MakeRange looks thus:
import { RangeIterator } from "./rangeIterator"; class MakeRange implements Iterable<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } [Symbol.iterator](): Iterator<number> { return new RangeIterator(this._first, this._last); } } for (let item of new MakeRange(0,10)) { console.log(item) }
To make illustration clear, all of the updated files that are part of the example is listed below
// main.ts import { RangeIterator } from "./rangeIterator"; class MakeRange implements Iterable<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } [Symbol.iterator](): Iterator<number> { return new RangeIterator(this._first, this._last); } } for (let item of new MakeRange(0,10)) { console.log(item) }
// rangeIterator.ts import { RangeResult } from "./rangeResult"; export class RangeIterator implements Iterator<number> { private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } next(value?: any): IteratorResult<number> { if (this._first < this._last) { return new RangeResult(this._first++, false) } else { return new RangeResult(undefined, true) } } }
// rangeResult.ts export class RangeResult implements IteratorResult<number> { done: boolean; value: number; constructor(value, done) { this.value = value; this.done = done; } }
Compiling the files, and executing the generated JavaScript by running node main.js would print 0 to 9 to the console.
Improving with Type Inference, Structural Typing and IterableIterator
The current implementation achieves our objective of making TypeScript confirm that the iteration protocol is adhered to at compile time. The only drawback now is that, it is a little bit verbose. The good news is that it can be improved. TypeScript comes with other features that we can deploy to reduce the verbosity. These features include:- Type Inference
- Structural Typing
- IterableIterator Interface
For the purpose of this post, it is enough to understand Type Inference as a feature of the TypeScript compiler that allows it to infer the types of values, without the need for explicit type annotation.
Structural Typing on the other hand is a feature that allows us to create values of a type based on the shape of objects only. This means we can have values satisfying interfaces without having to create a class.
For more information on Type Inference, see the Type Inference section of the TypeScript handbook, and for more information about Structural Typing see the Type Compatibility section of the TypeScript handbook.
IterableIterator Interface, on the other hand is an interface defined by TypeScript that combines the contracts of Iterables and Iterator into one. This is because, in some cases, it makes sense to have the Iterable as an Iterator itself, removing the need to have an external class that serves as the iterator.
Bringing all of the above to use, we can delete rangeIterator.ts and rangeResult.ts and update the implementation of the MakeRange class in main.ts as follows:
class MakeRange { // no need to explicitly implement the interface private _first: number; private _last: number constructor(first, last) { this._first = first; this._last = last; } // no need to explicitly have IterableIterator<number> as return type [Symbol.iterator]() { return this; } // no need to explicitly have IteratorResult<number> as return type. next() { if (this._first < this._last) { return {value: this._first++, done: false} } else { return {value: undefined, done: true} } } } for (let item of new MakeRange(0,10)) { console.log(item) }
Compiling and running the corresponding main.js would print out 0 to 9, as expected.
Next up.
The next post is Generators and Iterators In JavaScript. It is the last in this series. In it, we would quickly explore generators in JavaScript and see how they can be put to use in defining Iterators.
I am writing a book: TypeScript Beyond The Basics. Sign up here to be notified when it is ready.
3 comments:
Update to TypeScript 3.6 please
Just wanted to make clear to people that there is a difference between your MakeRange from when it was an Iterable to an IterableInterator that you don't seem to mention.
const range = new MakeRange(0, 10); // Iterable
for ( let item of range ) { console.log(); } // Iterable first time: 0,1,2,3....
for ( let item of range ) { console.log(); } // Iterable second time: 0,1,2,3...
const range = new MakeRange(0, 10); // IterableIterator
for ( let item of range ) { console.log(); } // IterableIterator first time: 0,1,2,3....
for ( let item of range ) { console.log(); } // IterableIterator second time: nothing
Awesome post, thanks.
Post a Comment