ES6 modules with TypeScript and webpack

ES6 modules are the future (or, if you're reading this from the future, they are the present). If you haven't yet jumped onto the bandwagon, this guide will show you how. This is part 2 of my ongoing series on integrating TypeScript and webpack.

Part 1. TypeScript and webpack
Part 2. ES6 modules

Overview

If you're already using webpack then you're already using some kind of module format like CommonJS or AMD. If you're not already using modules then you should become familiar with the CommonJS format. Node.js has always used CommonJS and as a result most libraries published on npm use it. Since a large part of the power of webpack is pulling in dependencies directly from npm, it's definitely good to be familiar with the format before continuing with this guide. In addition, this guide is not meant to go over the specifics of the ES6 module format but rather how to integrate the format into your TypeScript and webpack workflow. I highly recommend reading about the ES6 module format here and here if you're not already familiar.

CommonJS interop

While ES6 modules are inspired by CommonJS, they have a fundamental difference that you should be aware of for interop purposes. CommonJS allows you to attach properties to an exports object or to assign the exports object directly.

Attaching properties
// foo.js
exports.foo = 'foo';

// app.js
var foo = require('./foo')  
// foo => {foo: 'foo'}
Assigning directly
// foo.js
module.exports = 'foo';

// app.js
var foo = require('./foo')  
// foo => 'foo'

With ES6 modules, however, you cannot assign the output of the module directly. There is a corresponding concept called the "default export", but it is slightly different.

// foo.js
export var bar = 'bar'  
export default 'foo';

// app.js
import foo from './foo';  
// foo => 'foo'
import * as fooModule from './foo';  
// fooModule => { bar: 'bar', default: 'foo' }
import { default as defaultFoo } from './foo';  
// defaultFoo => 'foo'

The key takeaway here is that ES6 modules only have named exports, and even the default export is a special version of a named export that supports a shorthand import format.

This distinction is important when working with CommonJS modules from an ES6 module, and vice versa, when exposing a library written in ES6 modules that should be consumable by a CommonJS module.

TypeScript support

TypeScript added support for ES6 modules in version 1.5. Support has been improving in version 1.6 so we'll use that for this guide. TypeScript 1.6 can be used by installing the typescript@next npm package.

TypeScript can be configured in two different ways: --target es6 or --target es5 --module commonjs. If TypeScript is set to target ES6 then it will output ES6 without transpilation. When set to target ES5 with CommonJS modules TypeScript will transpile your ES6 modules into a CommonJS format. We will discuss both approaches.

Configuring webpack

webpack currently does not support ES6 modules natively (coming in webpack 2). This means that if you configure TypeScript to target ES6 then you will need to use Babel before your application can be bundled.

When targeting ES6 your configuration should look something like this:

// tsconfig.json
{
  "compilerOptions": {
    "target": "es6"
  },
  "files": [...]
}

// webpack.config.js
module.exports = {  
  ...
  module: {
    loaders: [
      // note that babel-loader is configured to run after ts-loader
      { test: /\.ts(x?)$/, loader: 'babel-loader!ts-loader' }
    ]
  }
}

When targeting ES5 your configuration should look something like this:

// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs"
  },
  "files": [...]
}

// webpack.config.js
module.exports = {  
  ...
  module: {
    loaders: [
      // note that babel-loader is not required
      { test: /\.ts(x?)$/, loader: 'ts-loader' }
    ]
  }
}

Importing libraries

When writing your application or library in ES6 it can be a little confusing how to import modules published on npm that are typically written in CommonJS. I want to go through common scenarios to show how to handle each case.

CommonJS library

This will likely be the most common scenario for now. How to deal with this differs slightly between whether or nor you're targeting ES6 or ES5.

ES6

You can use the full range of ES6 import syntax without issue since Babel provides a compatibility layer. Note, however, that you may need to modify the declaration file for the library for TypeScript support (see below).

ES5

TypeScript does not provide the same compatibility layer when importing CommonJS modules using ES6 syntax. In practice this means that you cannot use the "default import" syntax to import the module. You can use other ES6 syntax however:

import someLib from 'someLib'; // this will throw an error  
import * as someLib from 'someLib'; // this will work  
import { someProp } from 'someLib'; // this will also work  

It's also possible to fall back to CommonJS syntax in this case:

import someLib = require('someLib');  

You generally will not need to make any changes to declaration files when targeting ES5.

ES6 library transpiled to CommonJS

You can identify this type of library by inspecting the source of the main module. If the module has code that looks something like this...

    Object.defineProperty(exports, '__esModule', { value: true });

... then you're likely dealing with this scenario.

Both TypeScript and Babel can seamlessly handle this scenario. You can use the full range of ES6 import syntax.

TypeScript relies on the declaration file to accurately identify the format of the module for this to work properly. If TypeScript thinks the module is a CommonJS module then you may run into issues. See below for more info.

Babel relies on the aforementioned __esModule flag that is exported with the module which it checks to determine if the module is pure CommonJS or if it's ES6 transpiled to CommonJS.

Pure ES6 library

I'm not personally aware of any libraries like this but it will likely occur more and more often. The important part here is that webpack will not be able to natively import such a library so you will need to first run it through babel-loader.

// webpack.config.js
var path = require('path');  
var someLibPath = path.dirname(require.resolve('someLib'));

module.exports = {  
  ...
  module: {
    loaders: [{ 
      test: function(absPath) { 
        return absPath.indexOf(someLibPath) == 0 
      }, 
      loader: 'babel-loader'
    }]
  }
}

Once webpack can bundle the libary then the above "ES6 library transpiled to CommonJS" scenario applies.

Typings

For external libraries, TypeScript relies on the declaration file to determine whether or not the library module is treated as CommonJS or ES6. Most declaration files on DefinitelyTyped today are CommonJS, even if the library could be treated as ES6. If you find this to be the case you may need to manually modify the declaration file.

In particular, if these things are true...

  1. The library has the __esModule flag or you are using babel-loader
  2. The declaration file uses export = syntax instead of export default

... then you will likely need to modify the declaration file. Most declarations take this form:

declare namespace MyLib {  
  export function foo(): string;
  ...
}

declare module 'mylib' {  
  export = MyLib;
}

You should modify it to look like this:

declare module 'mylib' {  
  export function foo(): string;
  ...

  // if the module has a default export
  export default X; // where X is the actual default export

  // if you're using Babel and referencing a CommonJS module
  // or if the ES6 module just re-exports everything as the default
  import * as MyLib from 'mylib';
  export default MyLib;
}

This change will notify TypeScript that the library should be treated as ES6.

Packaging as a library

So far we've really only addressed the import side of things. For writing a web application that is enough and you can skip this section. But for writing a reusable library there are further considerations. In particular, what happens when a CommonJS user (basically any Node.js user) imports your ES6 -> CommonJS transpiled library? Consider the following:

// myLib.ts
export default 'foo';

// some_other_CommonJS_consumer.js
var myLib = require('myLib');  

In the above scenario you might expect myLib to equal 'foo', but if you have configured TypeScript for the ES5 target you would be wrong. Instead myLib would equal { default: 'foo' }.

The best way to handle this scenario is to target ES6 and use Babel. Babel has a convenience feature where if a module only has a default export and no other exports, the module will be compiled in such a way that the above code will work as expected.

Here is example configuration for achieving this.

// tsconfig.json
{
  "compilerOptions": {
    "target": "es6"
  },
  "files": [...]
}

// webpack.config.js
module.exports = {  
  ...
  output: {
    filename: 'index.js',
    library: true,
    libraryTarget: 'commonjs2'
  },
  target: 'node',
  module: {
    loaders: [
      // note that babel-loader is configured to run after ts-loader
      { test: /\.ts(x?)$/, loader: 'babel-loader!ts-loader' }
    ]
  }
}

More information on using webpack for bundling libraries can be found here and here.

Additional Resources