Sunday, June 23, 2019

Typing Iterables and Iterators with TypeScript

In this post, we are going to do a quick exploration of how TypeScript allows for compile time guarantees when working with the iteration protocol in JavaScript.

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
We would look at these interfaces in this post.

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

If you are using an IDE with TypeScript support, the above code would lead to the IDE showing errors.

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
We will not explore in depth what Type Inference and Structural typing are in this post.

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.


No comments: