Advanced Static Types in TypeScript

This course explores the capabilities of TypeScript’s type system and shows how to use advanced static types in practice.

Watch the Course

TypeScript 2.4: Dynamic import() Expressions

TypeScript 2.4 added support for dynamic import() expressions, which allow you to asynchronously load and execute ECMAScript modules on demand.

At the time of writing in January 2018, the official TC39 proposal for dynamic import() expressions is at stage 3 of the TC39 process and has been for a while, which means it's likely that dynamic import() expressions are going to be standardized as part of ECMAScript 2018 or 2019.

Importing Modules with Static import Declarations

We'll start by looking at an example that does not use dynamic import() expressions to motivate why we need them in the first place.

Let's assume we've written a widget.ts module for some client-side widget:

import * as $ from "jquery";

export function render(container: HTMLElement) {
    $(container).text("Hello, World!");
}

Our widget needs jQuery and therefore imports $ from the jquery npm package. Note that we're using a fully static import declaration in line 1, not a dynamic import() expression.

Now let's switch over to the main.ts module and let's say that we want to render our widget into a specific <div> container. We only want to render the widget if we can find the container in the DOM; otherwise, we silently give up:

import * as widget from "./widget";

function renderWidget() {
    const container = document.getElementById("widget");
    if (container !== null) {
        widget.render(container);
    }
}

renderWidget();

If we now bundle our application using a tool like webpack or Rollup with main.ts as our entry module, the resulting JavaScript bundle (in its unminified state) is over 10,000 lines long. This is because in our widget.ts module, we're importing the jquery npm package, which is quite large.

The problem is that we're importing our widget and all of its dependencies, even if we're not rendering the widget. The first time a new user opens our web application, their browser has to download and parse a lot of dead code. This is particularly bad on mobile devices with flaky network connections, low bandwidth, and limited processing power.

Let's see how we can do better using dynamic import() expressions.

Importing Modules with Dynamic import() Expressions

A better approach would be to only import the widget module if it's actually needed. However, ES2015 import declarations are fully static and have to be at the top-level of a file, which means we can't nest them within if-statements to conditionally import modules. This is where dynamic import() expressions come into play!

In our main.ts module, we'll delete the import declaration at the top of the file and load our widget dynamically using and import() expression, but only if we did in fact find the widget container:

function renderWidget() {
    const container = document.getElementById("widget");
    if (container !== null) {
        import("./widget").then(widget => {
            widget.render(container);
        });
    }
}

renderWidget();

An import(specifier) expression is a special syntactic form for loading a module. The syntax is reminiscent of a function call that passes a specifier string. That specifier string can be dynamically computed — something that isn't possible with static import declarations.

Since fetching an ECMAScript module on demand is an asynchronous operation, an import() expression always returns a promise. That promise resolves once the widget module and all its dependencies have feen fetched, instantiated, and evaluated successfully.

Using the await Operator with import()

Let's do a little refactoring to make our renderWidget function less nested and thus easier to read. Because import() returns a plain ES2015 promise (which has a .then() method), we can use the await operator to wait for the promise to resolve:

async function renderWidget() {
    const container = document.getElementById("widget");
    if (container !== null) {
        const widget = await import("./widget");
        widget.render(container);
    }
}

renderWidget();

Nice and clean! Don't forget to make the renderWidget function asynchronous by adding the async keyword to its declaration.

If you're not quite sure how async and await work, check out my Asynchronous JavaScript with async/await video course. It's only 18 minutes long — perfect for your next coffee break!

Asynchronous JavaScript with async/await

Targeting Various Module Systems

The TypeScript compiler supports various JavaScript module systems such as ES2015, CommonJS, or AMD. Depending on the target module system, the JavaScript code that is generated for import() expressions will be quite different.

One restriction is that you cannot compile import() expressions to ES2015 modules because their dynamic and potentially conditional nature cannot be represented using static import declarations.

If we compile our TypeScript application with --module esnext, the following JavaScript code will be generated. It is almost identical to the code we've written ourselves:

"use strict";
function renderWidget() {
    var container = document.getElementById("widget");
    if (container !== null) {
        var widget = import("./widget").then(function (widget) {
            widget.render(container);
        });
    }
}
renderWidget();

Notice that the import() expression has not been transformed in any way. If we had used any import or export declarations in this module, those would've been left untouched as well.

Compare this to the following code that is generated when we compile our application with --module commonjs (with some additional line breaks for readability):

"use strict";
function renderWidget() {
    var container = document.getElementById("widget");
    if (container !== null) {
        var widget = Promise
            .resolve()
            .then(function () {
                return require("./widget");
            })
            .then(function (widget) {
                widget.render(container);
            });
    }
}
renderWidget();

CommonJS would be a good choice for a Node application. All import() expressions will be translated to require() calls, which can conditionally executed at an arbitrary point in your program without having to load, parse, and execute the module upfront.

So which module system would you target in a client-side web application that uses import() to lazy-load modules on demand? I recommend you use --module esnext in conjunction with webpack's code splitting feature. Check out Code-Splitting a TypeScript Application with import() and webpack for a demo application setup.