DotNetCurry Logo

Using TypeScript to implement Multi-Platform Libraries

Posted by: Francesco Abbruzzese , on 5/31/2017, in Category TypeScript
Views: 8161
Abstract: Target UMD, globals and ES6 platforms simultaneously using a single TypeScript library and a simple Node.js script

TypeScript can target several JavaScript versions and several module export conventions (AMD, CommonJS, System, ES6, or simply globals + namespaces).

However, in general, each platform requires different configuration settings and a different module structure. As a result, there is no built-in option to simultaneously generate JavaScript and declaration files (d.ts) from a single TypeScript source file for several platforms.

More specifically AMD and CommonJS conventions are covered simultaneously by selecting the UMD module target; but globals and ES6, require different settings.

This article is published from the DNC Magazine for Developers and Architects. Download or Subscribe to this Free magazine [PDF] to access all previous, current and upcoming editions.

In this how-to article, I will show how to organize your TypeScript project to target UMD, globals and ES6 platforms simultaneously, all with the help of a simple Node.js script.

This script will do the following:

  • Pre-process TypeScript sources to adapt them to each target platform
  • Orchestrate the whole transformations pipeline that yields the final declaration files (d.ts) JavaScript files, as well as minified JavaScript files, for all platforms.

Editorial Note: This article assumes familiarity with TypeScript. If you are new to TypeScript, check this tutorial (bit.ly/dnc-ts-tut) to get started.

Multi-platform libraries

Usually, modern JavaScript libraries offer their functionalities through a unique access-object (such as the jQuery, or the underscore objects for instance) and expose this access-object in a way that is compatible with several module organization standards.

More specifically, they autodetect their environment and either expose themselves as AMD / CommonJS modules or expose this access-object in a global variable.

While CommonJS ensures compatibility with Node.js and Browserify, AMD ensures compatibility with the remaining loaders (RequireJs and SystemJs) and module bundlers (SystemJs build tool, RequireJs optimization tool, WebPack).

Although the JavaScript community considers the usage of global variables as obsolete, it still remains the preferred technique for web developers that rely mainly on server side techniques and use just a small portion of JavaScript in their web pages.

As a result, compatibility with the global variable pattern is a “must”.

Unluckily, the way the global variable exposes the library access-object, follows two different patterns: first, where in famous free libraries, the access-object is inserted directly in a global variable (for instance jQuery for the jQuery library), and the second, in proprietary libraries, all installed access-objects are exposed as properties of a root “company” object (something like myCompany.myLibrary).

Multi-platform compatibility and auto-detection are achieved with a JavaScript wrapper that surrounds the core code. When the library is bundled with other JavaScript code, this wrapper code might interfere with some bundler optimizations (mainly with unused code removal).

ES6 module organization doesn’t need any wrapper since the imported and exported objects, are defined with declarations. Moreover, its declarative organization helps bundlers in their optimizations.

Thus, if our library is likely to be bundled with a bundler like WebPack 2 that supports and takes advantage of ES6 module organization, it is a good idea to also deliver a version based on ES6 module organization.

We can also do this if we are targeting a lower JavaScript version instead of ES6, but since all import/export statements are removed by the bundler, it merges all modules into a unique “chunk”.

Implementing libraries with TypeScript

The way TypeScript is translated into JavaScript depends on the settings of the JSON configuration placed in the root of the TypeScript project.

In particular, the way modules are organized is controlled by the “module” property, whose value must be:

  • “none” for a global variable based structure
  • “UMD” covers both CommonJS and AMD
  • “es6” for the ES6 modules structure.

Thus, having different configuration files and different versions of the library is the only way to ensure compatibility with several platforms.

Unfortunately, just accepting the idea of several compilation steps and several versions of the same library is not enough to solve the problem, since the TypeScript sources must also be slightly changed to adapt them to different platforms.

In fact, the global variable version of the library should contain TypeScript namespaces and should look like this:

/// <reference path="./teams.ts" />
namespace company{
    export namespace organization{
        //core code
    }
}

..where the reference tags declare other modules that the current module depends on, and the nested namespaces ensure a global name like “companyName.libraryName”.

The ES6 version instead would look like this:

import {Person, Team} from "./teams"
//core code

..where the import statements declare all the objects contained in other modules and used by the current module.

Finally, the UMD version should be similar to the ES6 one, but at least one module would also contain some re-exports of names imported from other modules. In fact, usually AMD and CommonJS libraries are accessed through the single access-object returned by a unique “main module” that also exposes all the needed objects contained in other modules of the library.

So atleast for the main module, we should have something like this:

import {Person, Team} from "./teams"
export {Person, Team}
//core code

As a conclusion, we need a preprocessing of our sources.

Luckily, the examples above show that this preprocessing is quite simple and consists of:

  • Extracting the “core code” from each source module
  • Generating three different TypeScript files from each source by adding a different wrapper around the same “core code”.

So we may now proceed as follows:

1. We develop our modules in JavaScript using any of the styles outlined above (UMD, ES6, or global variable). The best candidate is ES6 since the code doesn’t depend on the way we want to deploy our library.

2. We mark the core code of each module by enclosing it between the two symbols ///{ and ///} as outlined below:

import {Person, Team} from "./teams"
///{
            //core code is here
///}

We then define three wrappers for each source module, one for each destination platform. In each wrapper, we mark a place where to put the “core code” with the symbol ///{}, as outlined below:

namespace company{
        export namespace organization{
            ///{}
        }
}

Once we have everything in place - our sources, the wrappers, and the three different TypeScript configuration files; we may launch a Node.js script that performs the needed preprocessing and compiles all preprocessed files yielding both JavaScript files, TypeScript declaration files (.d.ts), and the map files (.map).

The same script can also minify each JavaScript file with UglifyJS.

An example project

Let us analyze everything in detail with a simple example.

Preparing the project

As a first step, make sure you have installed a recent version of Node.js, and a recent version of the TypeScript compiler globally (the tsc command must be available in any directory).

In the remainder of the article, I will use Visual Studio Code, but if you want, you may execute all the steps with a different IDE, since I’ll not use any specific feature of Visual Studio Code.

Create a folder for the project, and then use Visual Studio Code to open it.

If not visible, show the Visual Studio Code integrated terminal (you may also open a command prompt in the project directory).

We will need UglifyJS, so let us install it in our folder with npm:

> npm install uglify-js

Finally add a “src” folder for our sources.

Defining the TypeScript configuration files

We need three different TypeScript configuration files, one for each target platform.

Since they differ just by a few settings, they may inherit most of their settings from a common configuration file.

Add a new file to the project and name it “tsconfig.base.json”, this file will contain the common configuration:

{
    "compilerOptions": {
        "moduleResolution":"classic",
        "noImplicitAny": true,
        "removeComments": true,
        "preserveConstEnums": true,
        "sourceMap": true,
        "declaration":true,
        "target":"es3",
        "strictNullChecks": false
    }
}

Now we may define the ES6 configuration as “tsconfig.es6.json”:

{
    "extends": "./tsconfig.base",
    "compilerOptions":{
        "declarationDir": "./dest/es6",
        "outDir":"./dest/es6",
        "module": "es6"
    },
    "include":[
        "proc/es6/**/*.*"
    ]
}

Where the “include” property specifies the source files, the “module” property specifies the kind of modules organization, and finally “declarationDir” and “outDir” are the folders where to place the output TypeScript declaration files, and JavaScript files respectively.

The UMD configuration as “tsconfig.umd.json”:

{
    "extends": "./tsconfig.base",
    "compilerOptions":{
        "declarationDir": "./dest/umd",
        "outDir":"./dest/umd",
        "module": "umd"
    },
    "include":[
        "proc/umd/**/*.*"
    ]
}

In this case, the module organization is “umd”, and both input and output folders are different.

Finally, the global variable configuration as “tsconfig.global.json” is as follows:

{
    "extends": "./tsconfig.base",
    "compilerOptions":{
        "declarationDir": "./dest/global",
        "outDir":"./dest/global",
         "module": "none"
    },
    "include":[
        "proc/global/**/*.*"
    ]
}

Here “module” “none” specifies that there are no modules at all, since we use namespaces instead.

The three different JSON files differ in the value of the “module” property, and in the location where they take their sources and output the result of the compilation.

All TypeScript sources are taken form a different subfolder of the “proc” folder.

In fact, our preprocessor will take all files from the “src” folder and from each of them, it will create three new modified versions - one in the “proc/global” folder, another in the “proc/es6” folder and the last one in the “proc/umd” folder.

Analogously, all compilation results will be placed in the dest/global”, “dest /es6”, and “dest /umd” folders.

Some example TypeScript modules

Let’s add a module containing some simple classes of “person” and “team” within our “src” folder. Let us call this file: “teams.ts”:

///{
export class Person
{
    name: string;
    surname: string;
    role: string;
}
export class Team 
{
    name: string;
    members: Person[]
    constructor(name: string, ...members: Person[]){
        this.name = name;
        this.members=members;
    }
    add(x: Person){
        this.members.push(x);
    }
}
///}

In this simple module, the “core code” enclosed within the ///{ and ///} symbols is the whole code.

Let’s also define the “projects.ts” module that contains the logic to assign teams and persons to projects:

import {Person, Team} from "./teams"
///{
export class Project
{
    name: string;
    description: string;
    team: Team|null;

    constructor(name: string, description: string)
    {
        this.name=name;
        this.description=description;
    }
    assignTo(team: Team)
    {
        this.team=team;
    }
    addHumanResource(x: Person): boolean
    {
        if(!this.team) return false;
        this.team.add(x);
        return true;
    }
}
///}

Since we need to import classes from the previous module in this module, we have added an import statement that is not part of the “core code” (all import, re-export and namespace definitions are excluded from the core code).

Defining all wrappers

Wrappers are defined in files placed in the “src” directory. We give them the same name as the module they refer to, but with different extensions.

The extensions are named .es6.add, .umd.add, and .global.add.

Shown here are all the wrappers for the teams module.

 teams.global.add:
namespace company{
    export namespace organization{
        ///{}
    }
}

teams.umd.add, and teams.es6.add are equal and contain just the placeholder for the “core code”, since they have no import statement:

///{}

projects.es6.add instead imports the classes from the teams module, so its content is as follows:

import {Person, Team} from "./teams"
///{}

Since the projects module is also the main module of the whole library, projects.umd.add not only imports the classes defined in the projects module, but also re-exports them, so its content is:

import {Person, Team} from "./teams"
export {Person, Team}
///{}

projects.global.add does not contain just namespaces definitions like teams.global.add but also a “reference declaration” and a variable definition for each object imported from the teams module:

/// <reference path="./teams.ts" />
namespace company{
    export namespace organization{
        let Team = organization.Team;
        let Person = organization.Person;
    }
}

Variable declarations must be provided in a “.global.add” wrapper each time the source module contains imports, otherwise all references to the imported objects in the “core code” would be undefined.

Building the project

I used a simple Node.js script to perform the needed preprocessing, to call the TypeScript compiler, and to minimize all files. However, you can just take the pre-processing part of the script and organize all other tasks with Gulp or Grunt.

I placed this script in the root of the project and called it “go.js” but you may use a different name such as “build.js”.

In the header of the file, add all the needed requires:

var exec = require('child_process').exec;
var fs = require("fs");
var path = require('path');
var UglifyJS = require("uglify-js");

“fs” and “path” are needed for files and directory processing, “Uglify-js” (which was installed when preparing the project) for the minimization of the target JavaScript files, and finally “child_process” to launch the “tsc” command (TypeScript compilation).

Then comes a few settings:

var src="./src";
var dest = "./proc";
var fdest ="./dest";
var dirs = [
  'global',
  'umd',
  'es6' 
];
var except = [
  'require','exports','module', 'export', 'default'
];

They contain all directory names. You may change them, but then you also have to coherently change wrappers extensions, the names of the TypeScript configuration files and also all directories referred in this configuration files.

The “except” variable is passed to UglifyJS and contains all “reserved words” that must not be mangled. If needed, you may add more names, but please do not remove the existing names, since they are used in the UMD wrapper created by the TypeScript compiler.

Before starting the actual processing, I define a couple of utilities to extract “core code”, and to collect input file names:

var extract = function(x){
  var startIndex = x.indexOf("///{");
  if(startIndex < 0) return x;
  else startIndex=startIndex+4;
  var endIndex = x.lastIndexOf("///}");
  if(endIndex>0) return x.slice(startIndex, endIndex);
  else return x.slice(startIndex);
}

This extracts the “core code” from a string that contain a whole source file.

var walk = function(directoryName, ext) {
  var res=  [];
  ext=ext || ".ts";
  var files=fs.readdirSync(directoryName);   
  files.forEach(function(file) {
      let fullPath = path.join(directoryName,file);
      let f= fs.statSync(fullPath);
      if (f.isDirectory()) {
          res=res.concat(walk(fullPath));
      } else {
      if(fullPath.match(ext+"$"))
            res.push(fullPath)
      }
    });
  return res;
};

The “walk” function recursively collects the name of all files with extension “ext” contained in the directory “directory name” and in all its descendant directories.

With the above utilities defined, the pre-processing stage is easily implemented:

var toProcess=walk(src);
if(!fs.existsSync(dest)) fs.mkdirSync(dest);
dirs.forEach(function(dir){
  let outDir = path.join(dest, dir);
  if(!fs.existsSync(outDir)) fs.mkdirSync(outDir);
  toProcess.forEach(function(file){
    let toAdd=file.substr(0, file.length-3)+"."+dir+".add";
    let outFile=path.join(dest, dir, path.basename(file));
    
    if(fs.existsSync(toAdd)){
      let input = extract(fs.readFileSync(file, 'utf8'));
      fs.writeFileSync(outFile, 
          fs.readFileSync(toAdd, 'utf8').replace("///{}", input));
    }
    else{
      fs.writeFileSync(outFile, 
          fs.readFileSync(file));
    }
  })
});

The code collects all source files with the “walk” function, then creates the root directory for all pre-processed files if it doesn’t exist.

The outermost “forEach” loops through all platform specific directories. It creates them if they do not exist, and then the inner “forEach” loops through all source files.

Each source file is read into a string, its core is extracted by the “extract” function and placed in the “content area” of each wrapper file with a string replace.

The final chunk of code asynchronously invokes the TypeScript compiler and minimizes all compilation results, in the associated callback:

if(!fs.existsSync(fdest)) fs.mkdirSync(fdest);
for(let i=0; i<dirs.length; i++)
{
  let config = 'tsc -p tsconfig.'+dirs[i]+'.json';
  let fOutDir = path.join(fdest, dirs[i]);
  if(!fs.existsSync(fOutDir)) fs.mkdirSync(fOutDir);
  console.log("start "+dirs[i]);
  exec(config, function(error, stdout, stderr) {
    console.log(stdout);
    console.error(stderr);
    if(dirs[i] != 'es6') {
      let files = walk(fOutDir, ".js");
      files.forEach(function(file){
        let baseName=file.substr(0, file.length-3);
        if(baseName.match(".min$")) return;
        let inMap = file+".map";
        if(!fs.existsSync(inMap)) inMap=undefined;
        let outFile = baseName+".min.js";
        let outMap = baseName+".min.js.map";
        let res=UglifyJS.minify(file, {
          outSourceMap: path.basename(outMap),
          outFileName: path.basename(outFile),
          inSourceMap: inMap,
          except:except
        });
        fs.writeFileSync(outFile, res.code);
        fs.writeFileSync(outMap, res.map);
      });
    }
    console.log("finished "+dirs[i]);
  });
}

The TypeScript compiler is invoked three times, once for each typescript file defined. The name of the configuration file is passed as a command line argument. Before invoking the compiler, all needed destination directories are created if they do not exist.

In the callback, messages and possible errors are displayed in the console. For each input file that the compiler creates: the corresponding JavaScript file, a TypeScript declaration file, and a source map is generated.

This way TypeScript users of the library may both import library definitions, and debug the code.

Finally, UglifyJS is invoked and the source map created by the compiler is passed to it as input map. This way, the final source map of the minimized code will refer to the original TypeScript sources instead of the JavaScript intermediate file.

Testing the Node.js script

You may test the Node.js script on the example project.

In the Visual Studio Code integrated terminal (or in any command window rooted in the project folder), launch the command:

> node go

You should see the new “proc” and “dest” directories each containing 3 subdirectories: “es6”, “umd” and “global”.

The “proc” directory contains the pre-processed TypeScript files, while each “dest” folder subdirectory contains all minimization and compilation results namely : “projects.d.ts”, “projects.js”, “projects.js.map”, “projects.min.js”, and “projects.min.js.map”.

You may test the umd version of the example library with the help of the Node.js debugger integrated in Visual Studio Code.

Follow these steps:

Create a “test.js” file in the root of the project and fill it with the code below:

let projects=require("./dest/umd/projects.js")

var project = new projects.Project("test", "test description");
project.assignTo(
    new projects.Team("TypeScript team", 
    new projects.Person("Francesco", "Abbruzzese", "team leader")));
var team = project.team;

We will use the projects module to access all the classes, since in the umd version of the library, it acts as “main module” and exports all objects exposed by the library.

Put a breakpoint on the first line of code. Go to the Visual Studio Code debugger view and click the play button to start debugging. Execute the code step-by-step with the help of the “F10” key, and inspect the value of all variables by hovering over them with the mouse.

This way, you may verify that all classes are created properly.

Conclusion:

In summary, several platforms may be targeted simultaneously by a single TypeScript library by generating several versions of each module (one for each target platform), thanks to the simple pre-processing technique described.

The Node.js build-script proposed is just an example of implementation of the proposed pre-processing technique you may use as a starting point for your custom scripts.

Download the entire source code of this article (Github).

This article was technically reviewed by Ravi Kiran and Suprotim Agarwal.

Was this article worth reading? Share it with fellow developers too. Thanks!
Share on LinkedIn
Share on Google+
Further Reading - Articles You May Like!
Author
Francesco Abbruzzese (@F_Abbruzzese) implements ASP.NET MVC applications, and offers consultancy services since the beginning of this technology. He is the author of the famous Mvc Controls Toolkit, and his company (www.mvc-controls.com/) offers tools, and services for ASP.NET MVC. He moved from decision support systems for banks and financial institutions, to the Video Games arena, and finally started his .NET adventure with the first .NET release. He also writes about .NET technologies in his blog: www.dotnet-programming.com/


Page copy protected against web site content infringement 	by Copyscape




Feedback - Leave us some adulation, criticism and everything in between!