Saturday, June 22, 2019

Iteration Protocol And Configuring The TypeScript Compiler

This post is part of the series of post on Symbols, Iterables, Iterators and Generators. It is part of various blog posts, where I penned down some of the things I learnt about TypeScript and JavaScript, while working on ip-num: A TypeScript library for working with ASN, IPv4, and IPv6 numbers.

This post would be about how to configure the TypeScript compiler in other to be able to work with the iteration protocol.

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. I have heard it described in some quarters as "JavaScript that scales".

The core feature of TypeScript that allows it to be described as such is the static guarantees and compile time safety it introduces, which allows invariants and structure about code base to be validated and checked for correctness at compile time.

In previous posts in this series, I have looked at Symbols In JavaScript and Iterables and Iterators in JavaScript. One of the things that stands out in these posts, is the requirement to have objects that have certain structure or adhere to stipulated protocols in other to be considered as Iterables or Iterators. Formally, these sets of requirement are known as the Iteration protocol

For example, for something to be considered an Iterable, it needs to adhere to the stipulated requirement that such a thing must have a Symbol.iterator method that returns an Iterator.

For something to be considered an Iterator, it needs to adhere to the requirement of having at least a next method.

And for the value returned from calling the next method on an iterator to be considered valid, it needs to be of a certain shape; that is the value must have a done property and a value property.

So it is obvious that for things to work out fine when working with the iteration protocal, a lot of requirements and structure needs to be satisfied. This is where TypeScript can help.

TypeScript allows us to code while making sure the checks and guarantees required by the iteration protocal are present and can be confirmed at compile time.

To do this, TypeScript provides a couple of interfaces that capture the structures and protocols of iterables and iterators. These interface can then be implemented at development time ensuring the requirements are adhered to.

But in other to be able to make use of these interfaces and compile time guarantees, we need to be able to configure the Typescript compiler appropriately. This post will show how to go about such configuration and it will contain the following sections:



A short note about compile time vs runtime API in TypeScript

JavaScript application can be run in a couple of different environments: on the client side, via web browsers and on the server side, via NodeJS. Apart from these environments, JavaScript can also be run in various host environment as embedded scripting language. Such environments include places like web browser extensions, within Acrobat/Adobe Reader, within LibreOffice etc.

What this means, is that, the APIs available at runtime differs depending on which environment the JavaScript code is running on. For example, by default, the DOM api won’t be available in the server side node environment.

Also, JavaScript’s API and functionality is versioned via ECMAScript. This means that the API available at runtime also depends on which version of ECMAScript the running environment supports. For example the Promise API, native Map and Set etc are only available within environments that support ES6 or greater.

It does not end here, since JavaScript can also be run as embedded scripting language in various host environments, with slimmed down requirements, It is not strange to want to program against only a subset of features and API from a particular specification. For example, it might be enough to program against a subset of features of ES6 and not the full specification due to the nature of the target host environment.

The repercussions of all the above is that APIs available at compile time might be different from API’s available at runtime. This means while developing with TypeScript, one needs to be aware of the environment the compiled TypeScript will be running so that we can enforce the right set of compile time guarantees that fits the given target environment.

For example, If I am writing a TypeScript application that I know will only be run in the server via NodeJS, there is no need to ask TypeScript to do static compile time checks that confirm the proper usage of the Dom API.

In other to ensure that the compiler checks, at compile time, that would make the most sense depending on the target environment we can configure the TypeScript compiler appropriately either by specifying:
  • Option A: The ECMAScript version we want to target (which will automatically make all the API and language features for that specified ECMAScript version available at compile time)
  • Option B: The sets of API’s and features we want to use within a specific ECMAScript target 
These compiler configurations can be passed in either as a command line argument or within a tsconfig.json file. For the discussion in this post, compiler configurations will be specified in tsconfig.json file.

To go with the configuration approach specified as Option A above, we use the target property of the config object in the tsconfig.json file, where the allowed values include: "es3", "es5", "es6", "es2015", "es2016", "es2017", "es2018", "esnext": a list that will definitely be updated as the ECMAScript standard evolves.

Specifying any of this value will make a preconfigured set of API’s available at compile time, with the expectation that same set will be provided by the environment the code will be run. For example if ES5 is specified, the DOM api, ES5 features, and ScriptHost API will be made available at compile time. Such generated can then easily be run in a web browser environment since such environment makes the above APIs available.

An example of such a configuration will be:

{
  "include": ["src/*","spec/*"],
  "compileOnSave": true,
     ...
      "target": "es5" // specifying target
     ...
    }
}

In situation where there is a need to have more control over the APIs available for compile time, instead of the stock API provided for a selected target, we go with the Option B style of configuration, in which we use the lib property to select the APIs we want to be available. Such configuration will be:

{
  "include": ["src/*","spec/*"],
  "compileOnSave": true,
    "compilerOptions": {
      "lib": ["DOM.Iterable", “WebWorker”], // specifying exact API
      "target": "es5", // specifying target
    }
}

The supported values for lib are much more than that of target, and can be found here

Note that apart from es3, all valid ECMAScript values for target property are also valid for lib property. What this means is that when such a value is used it will automatically include the APIs predefined for the specified ECMAScript version.

But is this not the same as using the target property directly?

Well, not really.

Yes it is, if the specified value for the lib property is the same value specified in target. Such a configuration is redundant.

What the lib allows us to do, is to specify a newer, higher version of ES standard in lib, and have a lower version in target. By doing this, we can code with newer API and features and upon compilation, the Typescript compiler will now down transpile to a compatible and equivalent code using features available only in the lower/older ES standard specified in the target.

It should be noted that this is not always possible, for example there is no way to use the DOM api at compile time, and have it compiled down to equivalent code that can run in an environment that does not have the DOM api: for such instances, a polyfill is required.

Hence, a successful compilation of TypeScript to JavaScript does not automatically translate to a successful runtime. There is still the need to ensure that the compiled JavaScript is being executed in an environment that has all the features and API it requires, either by ensuring the Typescript compiler can transpile down to the lower environment or by making sure appropriate polyfills are available at runtime.

Now let us take the information presented above, regarding how the lib and target property works and apply it in configuring the Typescript compiler, in other to be able to have compile time guarantees when working with the Iteration protocol.


Configuring built-in API declarations

Iterables, Iterators and Symbols were all added to JavaScript with ES6. In this section we would look at various ways of configuring a TypeScript project to make use of these features.

For illustration purposes I will be using the MakeRange Iterable class that was introduced in Iterables and Iterators in JavaScript but modified to TypeScript syntax:

class MakeRange {
    private _first: number;
    private _last: number
    constructor(first, last) {
     this._first = first;
     this._last = last;
    }
  
    [Symbol.iterator]() {
        return {
            next: () => {
                  if (this._first < this._last) {
                      return {value: this._first++, done: false}
                  } else {
                      return {value: undefined, done: true}
                  }
            }
        }
    }
   }

To be able to compile the file, with different compiler configurations, the above code should be saved in a directory, in with a tsconfig.json file will be present.

Assuming the above class is saved as main.ts in a directory named playground. Its content should be:

user:playground user$ tree
.
├── main.ts
└── tsconfig.json

To compile the TypeScript file, using the configuration settings, the following command will be executed at the terminal:

tsc -p tsconfig.json

Let us now go over some configuration options:

Targeting ES6 and higher using all ES6 API

Since iterables and iterators were added in ES6 specifying es6 as value of the target is enough to configure the compiler properly.

The tsconfig.json would look thus:

{
      "compilerOptions": {
        "target": "es6",
      }
}

The above configuration is enough to compile the main.ts successfully.

Targeting ES6 and higher using only API relating to Iteration protocol

The following configuration will achieve this.

{
      "compilerOptions": {
        "lib": [“es5", "es2015.iterable", "es2015.symbol", "es2015.symbol.wellknown"],
        "target": "es6",
      }
}

With this configuration, we are including all of ES5 APIs and features, and then only include the APIs related to the iteration protocol. These are "es2015.iterable", "es2015.symbol", "es2015.symbol.wellknown".

Targeting ES5 and using API from ES6 or Higher in development
For the case where we want to code against more recent ES specification but have the compiled code work in ES5, we can have the compiler configuration as such:

{
      "compilerOptions": {
        "lib": ["es7"],
        "target": "es5",
      }
}

Note that we would need to update the configuration if we have code that makes use of features like for...of syntax or spread syntax that are only available in ES6 and higher.

To illustrate this, we update the main.ts file to include code that makes use of the for...of syntax.

The updated file is as follows:

class MakeRange {
    private _first: number;
    private _last: number
    constructor(first, last) {
     this._first = first;
     this._last = last;
    }
  
    [Symbol.iterator]() {
        return {
            next: () => {
                  if (this._first < this._last) {
                      return {value: this._first++, done: false}
                  } else {
                      return {value: undefined, done: true}
                  }
            }
        }
    }
   }

// making use of features not available in es5
for (let item of new MakeRange(0,10)) {
    console.log(item)
}

Trying to compile the code now will lead to the following error:

main.ts:23:5 - error TS2584: Cannot find name 'console'. 
Do you need to change your target library? Try changing the `lib` compiler option to include 'dom'.

23     console.log(item)
       ~~~~~~~

But this has nothing to do with the for..of syntax just yet. The compilation fails because we are using console, which is an API that is only available via the DOM. To fix this, we update the configuration as follows:

{
      "compilerOptions": {
        "lib": ["es7", "dom"], // added dom
        "target": "es5",
      }
}

Now, reattempting compilation, it fails with the following error message:

main.ts:22:18 - error TS2569: Type 'MakeRange' is not an array type or a string type. 
Use compiler option '--downlevelIteration' to allow iterating of iterators.

22 for (let item of new MakeRange(0,10)) {

The error message contains instructions on how to fix the issue.

The fix is basically setting downlevelIteration to true. This will instruct the TypeScript compiler to downgrade the use of the for...of syntax into a version that is compatible with ES5. The updated configuration will now look like:

{
      "compilerOptions": {
        "lib": ["dom", "es7"],
        "target": "es5",
        "downlevelIteration": true
      }
}

With this modification, the compilation now succeeds.

Targeting ES5 using only API relating to Iteration protocol

In the situation where we still want to target ES5 at runtime, but at development time, we only want to make use of the API relating to the iteration protocol, we can update the configuration as follows:

{
      "compilerOptions": {
        "lib": ["es5", "dom", "es2015.iterable", "es2015.symbol", "es2015.symbol.wellknown"],
        "target": "es5",
        "downlevelIteration": true
      }
}

Since the target is still es5, we still need to set downlevelIteration to true.

This is basically it regarding the required configurations.

Next up, we will look into the various interfaces that TypeScript provides, which allow us to take advantage of compile time guarantees when working with Iteration protocol in TypeScript.

No comments: