Marius Schulz
Marius Schulz
Front End Engineer

Compiling async/await to ES3/ES5 in TypeScript

TypeScript has supported the async/await keywords since version 1.7, which came out in November of 2015. The compiler transformed asynchronous functions to generator functions using yield. However, this meant that you couldn't target ES3 or ES5 because generators were only introduced in ES2015.

Luckily, TypeScript 2.1 now supports compiling asynchronous functions to ES3 and ES5. Just like the rest of the emitted code, they run in all JavaScript environments. (That even includes IE6, although I hope that you're not forced to support such ancient browsers anymore.)

#Using Asynchronous Functions

Here's a simple function that resolves a promises after a given number of milliseconds. It uses the built-in setTimeout function to call the resolve callback after ms milliseconds have passed:

function delay(ms: number) {
  return new Promise<void>(function(resolve) {
    setTimeout(resolve, ms);
  });
}

The delay function returns a promise, which can then be awaited by a caller, like this:

async function asyncAwait() {
  console.log("Knock, knock!");

  await delay(1000);
  console.log("Who's there?");

  await delay(1000);
  console.log("async/await!");
}

If you now call the asyncAwait function, you'll see the three messages appear in the console, one after the other with a pause in between each:

asyncAwait();

// [After 0s] Knock, knock!
// [After 1s] Who's there?
// [After 2s] async/await!

Let's now see what the JavaScript code looks like that TypeScript emits when targeting ES2017, ES2016/ES2015, and ES5/ES3.

#Compiling async/await to ES2017

Asynchronous functions are a JavaScript language feature that is to be standardized in ES2017. Therefore, the TypeScript compiler doesn't have to rewrite async/await to some other construct when targeting ES2017 because both asynchronous functions are already supported natively. The resulting JavaScript code is identical to the TypeScript code, except that it has been stripped of all type annotations and blank lines:

function delay(ms) {
  return new Promise(function(resolve) {
    setTimeout(resolve, ms);
  });
}
async function asyncAwait() {
  console.log("Knock, knock!");
  await delay(1000);
  console.log("Who's there?");
  await delay(1000);
  console.log("async/await!");
}

There's not much more to talk about here. This is the code we wrote ourselves, just without type annotations.

#Compiling async/await to ES2015/ES2016

When targeting ES2015, the TypeScript compiler rewrites async/await using generator functions and the yield keyword. It also generates an __awaiter helper method as a runner for the asynchronous function. The resulting JavaScript for the above asyncAwait function looks as follows:

var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments)).next());
    });
};
function delay(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}
function asyncAwait() {
    return __awaiter(this, void 0, void 0, function* () {
        console.log("Knock, knock!");
        yield delay(1000);
        console.log("Who's there?");
        yield delay(1000);
        console.log("async/await!");
    });
}

The amount of helper code generated isn't negligible, but it's not too bad, either. If you'd like to use async/await within a Node 6.x or 7.x application, ES2015 or ES2016 is the language level you should be targeting.

Note that the only features that ES2016 standardizes are the exponentiation operator and the Array.prototype.includes method, neither of which is used here. Therefore, the resulting JavaScript code when targeting ES2016 is identical to the one generated when targeting ES2015.

#Compiling async/await to ES3/ES5

Here's where it gets interesting. If you develop client-side applications for the browser, you likely can't target ES2015 (or any higher language version) because the browser support just isn't good enough yet.

With TypeScript 2.1, you can have the compiler downlevel your asynchronous functions to ES3 or ES5. Here's what that looks like for our previous example:

var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments)).next());
    });
};
var __generator = (this && this.__generator) || function (thisArg, body) {
    var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t;
    return { next: verb(0), "throw": verb(1), "return": verb(2) };
    function verb(n) { return function (v) { return step([n, v]); }; }
    function step(op) {
        if (f) throw new TypeError("Generator is already executing.");
        while (_) try {
            if (f = 1, y && (t = y[op[0] & 2 ? "return" : op[0] ? "throw" : "next"]) && !(t = t.call(y, op[1])).done) return t;
            if (y = 0, t) op = [0, t.value];
            switch (op[0]) {
                case 0: case 1: t = op; break;
                case 4: _.label++; return { value: op[1], done: false };
                case 5: _.label++; y = op[1]; op = [0]; continue;
                case 7: op = _.ops.pop(); _.trys.pop(); continue;
                default:
                    if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
                    if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
                    if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
                    if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
                    if (t[2]) _.ops.pop();
                    _.trys.pop(); continue;
            }
            op = body.call(thisArg, _);
        } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
        if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
    }
};
function delay(ms) {
    return new Promise(function (resolve) {
        setTimeout(resolve, ms);
    });
}
function asyncAwait() {
    return __awaiter(this, void 0, void 0, function () {
        return __generator(this, function (_a) {
            switch (_a.label) {
                case 0:
                    console.log("Knock, knock!");
                    return [4 /*yield*/, delay(1000)];
                case 1:
                    _a.sent();
                    console.log("Who's there?");
                    return [4 /*yield*/, delay(1000)];
                case 2:
                    _a.sent();
                    console.log("async/await!");
                    return [2 /*return*/];
            }
        });
    });
}

Wow! That is a lot of helper code.

In addition to the __awaiter function that we've already seen before, the compiler injects another helper function called __generator, which uses a state machine to emulate a generator function that can be paused and resumed.

Note that, in order to have your code run successfully in ES3 or ES5 environments, you need to provide a Promise polyfill since promises were only introduced in ES2015. Also, you have to let TypeScript know that at run-time, it can assume to find a Promise function. Check out TypeScript 2.0: Built-In Type Declarations for more information.

And there you have it, async/await running in all JavaScript engines. Look out for the next part of this series, in which I'll explore how to avoid emitting these helper functions over and over for every TypeScript file in the compilation.

This article and 44 others are part of the TypeScript Evolution series. Have a look!