13. Async/Await in TypeScript

Overview

The async/await keywords give developers a more concise way to write asynchronous, non-blocking programs. In this chapter, we'll learn all about this syntactic sugar, a term for more concise and expressive syntax, for promises and how it drives modern software development. We will look at common uses of async/await and discuss the landscape of asynchronous programming in TypeScript. By the end of this chapter, you will be able to implement async/await keywords in TypeScript and use them to write asynchronous programs.

Introduction

The previous chapter got you started on promises in TypeScript. While promises improved our ability to write asynchronous code without the ugliness of nested callbacks, developers still wanted a better way to write asynchronous code. The promise syntax is sometimes challenging for programmers with a background in the C family of languages, and so the "syntactic sugar" of async/await was proposed to be added to the ECMAScript specification.

In this chapter, we'll learn about the introduction of new asynchronous programming paradigms to the ECMAScript standard, examine the syntax, and look at their use in TypeScript. We'll also cover the new (as of the time of writing) top-level await feature, which allows asynchronous programming outside of an async function. We will again look at error handling in asynchronous programming and examine the pros and cons of using async/await syntax versus promises.

Readers who have been through the prior chapter will see that there is still some nesting involved in promises. While the flow is much easier to manage through multiple promises than it is with nested callbacks, we still have no mechanism by which we can return control to the top level.

For example, consider a getData function that returns a promise. The code that invokes this function will look something like this:

getData().then(data => {

  // do something with the data

});

We don't have any means to propagate the data value to the outer scope. We couldn't deal with that value in a subsequent scope. Some programmers may attempt to write code that looks like this:

let myData;

getData().then(data => {

  myData = data

});

console.log(myData);

This code will always log out undefined. It seems like it should work, but it won't because the promise callback won't be invoked until the promise returns. Asynchronous programming like this can be confusing and lead to lots of bugs. async/await solve this problem by allowing us to pause the execution of code pending the resolution of a promise. We can rewrite the preceding code using async/await syntax:

const myData = await getData();

console.log(myData);

We've gone from five lines of code to two. The synchronous operation of console.log will wait for the promise to resolve. The code is much more understandable, and we can store our variable at the top scope without nesting.

Because TypeScript is transpiled to JavaScript in most cases, we need to make sure that we select the correct target environment in order for our code to run. This topic will be dealt with in greater detail later in the chapter.

Evolution and Motivation

Although promises moved the needle considerably when it came to asynchronous programming paradigms, there remained a desire for a lighter syntax that relied less on explicitly declaring promise objects. Adding the async/await keywords to the ECMAScript specification would allow developers to reduce boilerplate and work with promises. The concept comes from the C# programming language, which in turn borrowed the concept of asynchronous workflows from F#.

An asynchronous function allows a program to continue normal operation even though that function call has yet to return. The program does not wait for that asynchronous function call to complete until the await keyword is found. More significantly, using await will not block the event loop. Even if we have paused part of a program to await the result of an asynchronous function call, other operations can still complete. The event loop is not blocked. For more on the event loop, return to Chapter 12, Guide to Promises in TypeScript.

The great thing about these keywords is that they are immediately compatible with promises. We can await any promise, thereby avoiding having to use the then() API. This capability means that along with the concept of promisification (see Chapter 12, Guide to Promises in TypeScript), we can use the latest syntax even when integrating with older libraries or modules. To demonstrate this, let's return to an example from the preceding chapter:

import { promises } from "fs";

promises.readFile('text.txt').then(file => console.log(file.toString()));

This example uses the promises API from the fs (filesystem) module from Node.js. The code reads a file from the local filesystem and logs the contents to the console. We can use await syntax with this code:

import { promises } from "fs";

const text = (await promises.readFile('text.txt')).toString();

console.log(text);

Note that in order to run this code, you must be able to use top-level await, which, at the time of this writing, requires a bit of extra setup. Refer to the section later in this chapter. The takeaway from this example is that we are still able to use the promises API from the fs module, even if we prefer async/await.

async/await in TypeScript

The maintainers of TypeScript begin work on supporting ECMAScript features when they are in stages 1 and 2 of the review process, but only formally release them when they reach stage 3.

TypeScript began offering experimental support for async functions in version 1.6, released in September 2015, and offered full support in version 1.7, released in November 2015. TypeScript programmers could work with this syntax a full year ahead of official browser and Node.js support.

Use of the async/await keywords in TypeScript does not vary much from JavaScript, but we do have an advantage in the ability to be more explicit about which functions should return promises and which should return a resolved value or throw an error.

One thing to be cognizant of when writing modern syntax in TypeScript is that most TypeScript code is transpiled to JavaScript for execution in a runtime, such as a web browser or Node.js. We need to understand the difference between transpilation and a polyfill. Transpilation will convert code from one syntax into another. In this case, we can write async/await code and transpile to an environment that only supports promise syntax. A polyfill adds missing language features. If our target environment doesn't even support promises, then transpiling async/await into promises won't do the trick. We will require a polyfill as well.

Exercise 13.01: Transpilation Targets

In this exercise, we will use a contrived "Hello World!" example to demonstrate how TypeScript handles the transpilation of the async /await keywords:

Note

The code files for this exercise can be found here: https://packt.link/NS8gY.

  1. Navigate to the Exercise01 folder and install dependencies with npm install:

    npm install

  2. That will install TypeScript and the TS Node execution environment. Now, execute the program included by typing npx ts-node target.ts. The result will be as follows:

    World!

    Hello

    World! printed before Hello.

  3. Open up target.ts and inspect the reason for this. This program creates a sayHello function, which internally creates a promise that resolves after one millisecond. You may notice that the program does exactly the same thing even if we remove the await keyword. That's OK. It's the different transpilation targets here that are interesting. When we run this program using TS Node, this will target the current Node.js version we're running. Assuming that's a recent version, async/await will be supported. Instead of doing that, let's try transpiling the code into JavaScript using TypeScript to see what happens.
  4. Now, open the tsconfig.json file and look at it:

    {

      "compilerOptions": {

        "target": "es5",

        "module": "commonjs",

        "strict": true,

        "esModuleInterop": true,

        "skipLibCheck": true,

        "forceConsistentCasingInFileNames": true

      }

    }

  5. The target option being set to es5 means that TypeScript will attempt to produce code that conforms to the ECMAScript5 specification. So let's give that a try:

    npx tsc

    No output means that it executed successfully.

  6. Check out the target.js file that was produced by TypeScript. The size of this file may vary depending on your TypeScript version, but the transpiled code module may be more than 50 lines:

    "use strict";

    var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

        function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }

        return new (P || (P = Promise))(function (resolve, reject) {

    //….

    sayHello();

    console.log('World!');

    Note

    The complete code can be found here: https://packt.link/HSmyX.

    We can execute the transpiled code by typing node target.js at the command prompt and we'll see that we get the same output as before.

    Promises are not part of the ECMAScript5 specification, so to generate code that will work in an ECMAScript5 environment, the transpiler had to create __awaiter and __generator functions to support promise-like functionality.

  7. Let's switch our target to es6. Open tsconfig.json and change the target property to es6:

    {

      "compilerOptions": {

        "target": "es6",

        "module": "commonjs",

        "strict": true,

        "esModuleInterop": true,

        "skipLibCheck": true,

        "forceConsistentCasingInFileNames": true

      }

    }

  8. Invoking the function with node target.js, we get exactly the same output as before. Now let's see what TypeScript did when it transpiled our source:

    "use strict";

    var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {

        function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }

        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) : adopt(result.value).then(fulfilled, rejected); }

            step((generator = generator.apply(thisArg, _arguments || [])).next());

        });

    };

    const sayHello = () => __awaiter(void 0, void 0, void 0, function* () {

        yield new Promise((resolve) => setTimeout(() => resolve(console.log('Hello')), 1));

    });

    sayHello();

    console.log('World!');

    The transpiled code is now 15 lines instead of over 50 because ECMAScript6 is much closer to supporting all the functionality we need than es5 is. The async/await keywords are not supported in ECMAScript6, but promises are, so TypeScript is leveraging promises to make the outputted code more concise.

  9. Now, let's change the target to esnext, run npx tsc one more time, and see what that output looks like:

    "use strict";

    const sayHello = async () => {

        await new Promise((resolve) => setTimeout(() => resolve(console.log('Hello')), 1));

    };

    sayHello();

    console.log('World!');

    That's very similar to our source code! Since async/await are supported in the latest ECMAScript specification, there's no need to transform.

  10. Older versions of TypeScript did not fully polyfill promises and async/await. Downgrade your TypeScript version with npm i -D [email protected], set your compilation target back to es5, and then try transpiling:

    npx tsc

    target.ts:1:18 - error TS2705: An async function or method in ES5/ES3 requires the 'Promise' constructor. Make sure you have a declaration for the 'Promise' constructor or include 'ES2015' in your `--lib` option.

    1 const sayHello = async () => {

                       ~~~~~~~~~~~~~

    target.ts:2:13 - error TS2693: 'Promise' only refers to a type, but is being used as a value here.

    2 await new Promise((resolve) =>

                  ~~~~~~~

    target.ts:2:22 - error TS7006: Parameter 'resolve' implicitly has an 'any' type.

    2 await new Promise((resolve) =>

    It doesn't work.

  11. If you bump up to es6, it will still fail:

    % npx tsc

    target.ts:3:30 - error TS2345: Argument of type 'void' is not assignable to parameter of type '{} | PromiseLike<{}> | undefined'.

    3 setTimeout(() => resolve(console.log('Hello')))

  12. Install the latest version of TypeScript with npm i -D [email protected] and then everything should work as before.

This aspect of TypeScript can be confusing for newcomers. TypeScript will not provide a polyfill for missing promises, but it will provide transformations to syntax that is functionally equivalent.

Choosing a Target

So how do we choose a compilation target? It's generally safe to use ES2017 or above unless you need to support outdated browsers, such as Internet Explorer, or deprecated Node.js versions. Sometimes, we have no choice but to support outdated browsers due to customer needs, but if we have any control over a Node.js runtime environment, it's advisable to update to a current, supported version. Doing this should allow us to use the latest TypeScript features.

Syntax

The two new keywords, async/await, are often found together, but not always. Let's look at the syntax for each of them individually.

async

The async keyword modifies a function. If a function declaration or function expression is used, it is placed before the function keyword. If an arrow function is used, the async keyword is placed before the argument list. Adding the async keyword to a function will cause the function to return a promise.

For example:

function addAsync(num1: number, num2: number) {

  return num1 + num2;

}

Just adding the async keyword to this simple function will make this function return a promise, which is now awaitable and thenable. Since there's nothing asynchronous in the function, the promise will resolve immediately.

The arrow function version of this could be written as follows:

const addAsync = async (num1: number, num2: number) => num1 + num2;

Exercise 13.02: The async Keyword

This exercise illustrates how adding the async keyword to a function makes it return a promise:

Note

The code files for this exercise can be found here: https://packt.link/BgujE.

  1. Examine the async.ts file:

    export const fn = async () => {

      return 'A Promise';

    };

    const result = fn();

    console.log(result);

    You might expect this program to log out A Promise, but let's see what actually happens when we run it:

    npx ts-node async.ts

    Promise { 'A Promise' }

  2. The async keyword wrapped the response in a promise. We can confirm that by removing the keyword and running the program again:

    npx ts-node async.ts

    A Promise

  3. Modifying our function with async is exactly equivalent to wrapping it in a promise. If we wanted to use promise syntax, we could write the program like this:

    export const fn = () => {

      return Promise.resolve('A Promise');

    };

    const result = fn();

    console.log(result);

  4. Again, running the program written this way will log out the unresolved promise:

    npx ts-node async.ts

    Promise { 'A Promise' }

Since we're using TypeScript and return types can be inferred, modifying a function with async guarantees that TypeScript will always see the function as returning a promise.

The async keyword causes the function it modifies to be wrapped in a promise. Whether you choose to do that explicitly by declaring a promise or by using the async keyword is often a matter of taste and style.

How can we resolve an async function? We'll come to await in a moment, but what about using then and the promise chaining we learned about in Chapter 12, Guide to Promises in TypeScript. Yes, that is also possible.

Exercise 13.03: Resolving an async Function with then

This exercise will teach you how to resolve an async function using then:

Note

The code files for this exercise can be found here: https://packt.link/4Bo4c.

  1. Create a new file called resolve.async.ts and enter the following code:

    export const fn = async () => {

      return 'A Promise';

    };

    const result = fn();

    result.then((message) => console.log(message));

  2. Execute this code by entering npx ts-node resolve.async.ts into your console and you'll see the expected text message logged, not an unresolved promise:

    A Promise

Even though we never explicitly declared a promise object, the use of async has ensured that our function will always return a promise.

await

The second half of this combo perhaps has greater value. The await keyword will attempt to resolve any promise before continuing. This will get us out of then chaining and allow us to write code that appears to be synchronous. One great benefit of using await is if we want to assign the result of an asynchronous call to some value and then do something with the value. Let's look at how that's done in a promise:

asyncFunc().then(result => {

  // do something with the result

});

That can work fine and, in fact, this kind of syntax is used widely, but it breaks down a little if we need to do something tricky with chaining:

asyncFuncOne().then(resultOne => {

  asyncFuncTwo(resultOne).then(resultTwo => {

    asyncFuncThree(resultTwo).then(resultThree => {

      // do something with resultThree

    });

  });

});

But wait a minute. I thought promises were supposed to get rid of callback hell?! It's actually not that ideal for this kind of chaining. Let's try using await instead:

const resultOne = await asyncFuncOne();

const resultTwo = await asyncFuncTwo(resultOne);

const resultThree = await asyncFuncThree(resultTwo);

// do something with resultThree

Most programmers would agree that this syntax is much cleaner and, in fact, this is one of the primary reasons why async/await were added to the language.

Exercise 13.04: The await Keyword

This exercise will show you how to resolve a promise using await:

Note

The code files for this exercise can be found here: https://packt.link/mUzGI.

  1. Create a file called await.ts and enter the following code:

    export const fn = async () => {

      return 'A Promise';

    };

    const resolveIt = async () => {

      const result = await fn();

      console.log(result);

    };

    resolveIt();

    Here we declare two async functions. One of them calls the other using await to resolve the promise and it should print out the string, rather than an unresolved promise.

  2. Run the file using npx ts-node await.ts and you should see the following output:

    A Promise

Why did we need to wrap await in a second function? That is because normally, await cannot be used outside of an async function. We'll discuss the top-level await feature later in this chapter, which is an exception to this rule. What about mixing await with promises? This can certainly be done.

Exercise 13.05: Awaiting a Promise

This exercise teaches you how you can use await with promises:

Note

The code files for this exercise can be found here: https://packt.link/mMDiw.

  1. Create a new file called await-promise.ts and enter the following code:

    export const resolveIt = async () => {

      const result = await Promise.resolve('A Promise');

      console.log(result);

    };

    resolveIt();

  2. Execute the code by entering npx ts-node await-promise.ts and you'll see the text output:

    A Promise

  3. A longer way to write this same code with a more explicit promise declaration would be:

    export const resolveIt = async () => {

      const p = new Promise((resolve) => resolve('A Promise'));

      const result = await p;

      console.log(result);

    };

    resolveIt();

    This code functions exactly the same:

  4. Enter npx ts-node src/await-promise.ts to verify that you get the following output:

    A Promise

Syntactic Sugar

The preceding exercises on async functions and promises are simply two different ways of expressing the exact same operation in TypeScript. Likewise, using await and resolving a promise with then are equivalent. The async/await keywords are what's known as "syntactic sugar," or code structures that enable more expressive syntax without changing the behavior of the program.

This means it is possible and, at times, even advisable to mix async/await syntax with promises. A very common reason for doing this would be because you are working with a library that was written to use promises, but you prefer async/await syntax. Another reason for mixing the two would be to handle exceptions more explicitly. We'll deal with exception handling in detail later in this chapter.

Exception Handling

We've been over how to turn then chaining into await, but what about catch? If a promise is rejected, the error will bubble up and must be caught in some way. Failing to catch an exception in the async/await world is just as damaging as failing to catch a promise rejection. In fact, it's exactly the same and async/await is just syntactic sugar on top of promises.

Failing to handle a rejected promise can lead to system failure where a program running in a web browser crashes, resulting in blank pages or broken functionality, thereby driving users away from your site. A failure to handle a rejected promise on the server side may cause a Node.js process to exit and a server to crash. Even if you have a self-healing system that attempts to bring your server back online, whatever job you were attempting to complete will have failed and frequently repeated restarts will make your infrastructure more expensive to run.

The most straightforward way to handle these errors is with try and catch blocks. This syntax is not unique to async/await and has been part of the ECMAScript specification since ECMAScript3. It is very simple and straightforward to use:

try {

  await someAsync();

} catch (e) {

  console.error(e);

}

Just as you can catch an error thrown from any of several chained promises, you can implement a similar pattern here:

try {

  await someAsync();

  await anotherAsync();

  await oneMoreAsync();

} catch (e) {

  console.error(e);

}

There may be cases where finer-grained exception handling is required. It is possible to nest these structures:

try {

  await someAsync();

  try {

    await anotherAsync();

  } catch (e) {

    // specific handling of this error

  }

  await oneMoreAsync();

} catch (e) {

  console.error(e);

}

However, writing code such as this negates most of the benefits of the async/await syntax. A better solution would be to throw specific error messages and test for them:

try {

  await someAsync();

  await anotherAsync();

  await oneMoreAsync();

} catch (e) {

  if(e instanceOf MyCustomError) {

    // some custom handling

  } else {

    console.error(e);

  }

}

With this technique, we can handle everything in the same block and avoid nesting and messy-looking code structures.

Exercise 13.06: Exception Handling

Let's see how we can implement error handling in a simple example. In this exercise, we will intentionally and explicitly throw an error from an async function and see how that implements the operation of our program:

Note

The code files for this exercise can be found here: https://packt.link/wbA8E.

  1. Start by creating a new file called error.ts and entering the following code:

    export const errorFn = async () => {

      throw new Error('An error has occurred!');

    };

    const asyncFn = async () => {

      await errorFn();

    };

    asyncFn();

  2. This program will, of course, always throw an error. When we execute it by entering npx ts-node error.ts into the console, we can see quite clearly that the error is not being handled properly:

    (node:29053) UnhandledPromiseRejectionWarning: Error: An error has occurred!

        at Object.exports.errorFn (/workshop/async-chapter/src/error.ts:2:9)

        at asyncFn (/workshop/async-chapter/src/error.ts:6:9)

        at Object.<anonymous> (/workshop/async-chapter/src/error.ts:9:1)

        at Module._compile (internal/modules/cjs/loader.js:1138:30)

        at Module.m._compile (/workshop/async-chapter/node_modules/ts-node/src/index.ts:858:23)

        at Module._extensions..js (internal/modules/cjs/loader.js:1158:10)

        at Object.require.extensions.<computed> [as .ts] (/workshop/async-chapter/node_modules/ts-node/src/index.ts:861:12)

        at Module.load (internal/modules/cjs/loader.js:986:32)

        at Function.Module._load (internal/modules/cjs/loader.js:879:14)

        at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)

    (node:29053) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)

    (node:29053) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

    Notice the deprecation warning. Not only is this an ugly stack trace, at some point in the future, exceptions such as this one will cause the Node.js process to exit. We clearly need to handle this exception!

  3. Fortunately, we can do so by simply surrounding the call with try and catch:

    export const errorFn = async () => {

      throw new Error('An error has occurred!');

    };

    const asyncFn = async () => {

      try {

        await errorFn();

      } catch (e) {

        console.error(e);

      }

    };

    asyncFn();

  4. Now, when we execute the program, we get a more orderly exception and stack trace logged:

    Error: An error has occurred!

        at Object.exports.errorFn (/workshop/async-chapter/src/error.ts:2:9)

        at asyncFn (/workshop/async-chapter/src/error.ts:7:11)

        at Object.<anonymous> (/workshop/async-chapter/src/error.ts:13:1)

        at Module._compile (internal/modules/cjs/loader.js:1138:30)

        at Module.m._compile (/workshop/node_modules/ts-node/src/index.ts:858:23)

        at Module._extensions..js (internal/modules/cjs/loader.js:1158:10)

        at Object.require.extensions.<computed> [as .ts] (/workshop/node_modules/ts-node/src/index.ts:861:12)

        at Module.load (internal/modules/cjs/loader.js:986:32)

        at Function.Module._load (internal/modules/cjs/loader.js:879:14)

        at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)

    Of course, that message only appears because we explicitly logged it out. We could instead choose to throw a default value or perform some other operation instead of logging the error.

  5. It's always a good idea to log an error if the system isn't behaving correctly, but depending on your system requirements, you might instead write something like this:

    const primaryFn = async () => {

      throw new Error('Primary System Offline!');

    };

    const secondaryFn = async () => {

      console.log('Aye aye!');

    };

    const asyncFn = async () => {

      try {

        await primaryFn();

      } catch (e) {

        console.warn(e);

        secondaryFn();

      }

    };

    asyncFn();

    In this case, we just throw a warning and fall back to the secondary system because this program was designed to be fault-tolerant. It's still a good idea to log the warning so that we can trace how our system is behaving. One more variation of this for now.

  6. Let's put our try and catch blocks at the top level and rewrite our program like this:

    export const errorFN = async () => {

      throw new Error('An error has occurred!');

    };

    const asyncFn = async () => {

      await errorFN();

    };

    try {

      asyncFn();

    } catch (e) {

      console.error(e);

    }

  7. This is the output that you get:

    Error: Primary System Offline!

        at primaryFn (C:UsersMaheshDocumentsChapter13_TypeScriptExercise13.06error-secondary.ts:2:9)

        at asyncFn (C:UsersMaheshDocumentsChapter13_TypeScriptExercise13.06error-secondary.ts:11:11)

        at Object.<anonymous> (C:UsersMaheshDocumentsChapter13_TypeScriptExercise13.06error-secondary.ts:18:1)

        at Module._compile (internal/modules/cjs/loader.js:1063:30)

        at Module.m._compile (C:UsersMaheshAppDataRoaming pm-cache\_npx13000 ode_modules s-nodesrcindex.ts:1056:23)

        at Module._extensions..js (internal/modules/cjs/loader.js:1092:10)

        at Object.require.extensions.<computed> [as .ts] (C:UsersMaheshAppDataRoaming pm-cache\_npx13000 ode_modules s-nodesrcindex.ts:1059:12)

        at Module.load (internal/modules/cjs/loader.js:928:32)

        at Function.Module._load (internal/modules/cjs/loader.js:769:14)

        at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)

    Aye aye!

    You may assume that the program might work the same as putting try and catch inside asyncFn, but actually, it will behave the same as no error handling at all. That's because we aren't awaiting the function inside the try block.

Top-Level await

Top-level await is a feature that allows the use of the await keyword at the module level, outside of any function. This allows a number of interesting patterns, such as waiting for a dependency to fully load by calling an asynchronous function before attempting to use it. Someday, top-level await may support some very exciting functional programming paradigms, but at the time of writing, it is still technically in preview mode, and so is not ready for widespread use. You may be reading this book at a time when top-level await is widely available and supported, and if so, you should definitely give it a look!

Writing code with top-level await is very straightforward. Here is a very short program that attempts to make use of it:

export const fn = async () => {

  return 'awaited!';

};

console.log(await fn());

This looks fine. Now let's see what happens when we try to execute it:

⨯ Unable to compile TypeScript:

src/top-level-await.ts:5:13 - error TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to 'esnext' or 'system', and the 'target' option is set to 'es2017' or higher.

5 console.log(await fn());

              ~~~~~

It's not supported, but it gives me some pointers. How can we make this work?

Top-level await requires NodeJS 14.8 or greater. This version of NodeJS entered LTS (long-term service) in October of 2020 and so is still new at the time of this writing. You can check your NodeJS version on the command line with node -v. If you aren't running version 14.8 or greater, there are some good utilities like nvm and n that will allow you to switch your version easily.

That, however, doesn't fix the problem. It seems that I will need to change my tsconfig.json target property to es2017 or higher and set the module property to esnext. Adding the module property means that I want to use ES modules, which is a relatively new way to handle modules and is beyond the scope of this book. To enable ES modules, I need to set the type property in my package.json file to module.

Now I've updated a couple of JSON files and am ready to try again:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /workshop/async-chapter/src/top-level-await.ts

    at Loader.defaultGetFormat [as _getFormat] (internal/modules/esm/get_format.js:65:15)

    at Loader.getFormat (internal/modules/esm/loader.js:113:42)

    at Loader.getModuleJob (internal/modules/esm/loader.js:243:31)

    at Loader.import (internal/modules/esm/loader.js:177:17)

It still isn't working. I'll need to do one more thing to make this work, and that is to enable the experimental feature in Node.js and instruct TS Node to allow ES modules (esm). This requires a longer command:

node --loader ts-node/esm.mjs top.ts

(node:91445) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time

(Use `node --trace-warnings ...` to show where the warning was created)

awaited!

But it works. Top-level await will likely become much easier and more intuitive to work with in the months and years ahead, so make sure to check the latest documentation for your runtime.

Promise Methods

In addition to the standard next and catch methods exposed by promises, there are a number of other convenience methods, such as all, allSettled, any, and race, that make working with promises nicer. How can they be used in the async/await world? They can actually work together quite nicely. For example, here is a use of Promise.all that employs then and catch. Given three promises, p1, p2, and p3:

Promise.all([p1, p2, p3])

  .then(values => console.log(values))

  .catch(e => console.error(e));

There isn't any kind of awaitAll operator, so if we want to execute our promises in parallel, we're still going to need to use Promise.all, but we can avoid chaining then and catch if we choose to:

try {

  const values = await Promise.all([p1, p2, p3]);

  console.log(values);

} catch (e) {

  console.error(e);

}

In this case, we might feel like the code isn't improved by the addition of await, since we've actually expanded it from three lines to six. Some may find this form more readable. As always, it's a matter of personal or team preference.

Exercise 13.07: async/await in Express.js

In this exercise, we will build a small web application using the popular Express framework. Although Express was written for the JavaScript language, typings have been published for it and it is fully usable with TypeScript. Express is an unopinionated, minimalist framework for building web applications. It's one of the oldest and most popular frameworks in use today.

For our simple application, we'll start a web server on port 8888 and accept GET requests. If that request has a name parameter in the query string, we will log the name in a file called names.txt. Then we'll greet the user. If there's no name in the query string, we log nothing and print out Hello World!:

Note

The code files for this exercise can be found here: https://packt.link/cG4r8.

Let's get started by installing the Express framework and typings.

  1. Enter npm i express to install Express as a dependency and npm i -D @types/express @types/node to install the typings that we'll need to support TypeScript.

    Remember the -D flag means that it's a devDependency that can be managed differently from a production dependency, although its use is optional.

  2. With our dependencies installed, let's create a file called express.ts. The first thing to do is import express, create the app, add a simple handler, and listen on port 8888:

    import express, { Request, Response } from 'express';

    const app = express();

    app.get('/', (req: Request, res: Response) => {

      res.send('OK');

    });

    app.listen(8888);

    This looks very much like your standard starter Express app, other than we're giving types to the Request and Response objects. This is already enormously useful as we'll be able to use IntelliSense and ascertain what methods we can call on those objects without having to look them up.

    Our requirements say that we need to listen for a name parameter in the query string. We might see a request that looks like http://localhost:8888/?name=Matt, to which we should respond Hello Matt!.

    The query string is in the Request object. If we delve into the typings, it is typed as follows:

    interface ParsedQs { [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[] }

    This basically means that it is a hash of key/value pairs and nested key/value pairs. In our case, we would expect to see a query object that looks like { name: 'Matt' }. Thus, we can get the name attribute by using const { name } = req.query;. Then we can respond to the request with something like res.send(`Hello ${name ?? 'World'}!`);. In this case, we're using the nullish coalesce operator (??) to say that we'll fall back to the World string if the name variable has a nullish (null or undefined) value. We could also use the fallback or logical OR operator, ||.

  3. The updated code now looks like this:

    import express, { Request, Response } from 'express';

    const app = express();

    app.get('/', (req: Request, res: Response) => {

      const { name } = req.query;

      res.send(`Hello ${name ?? 'World'}!`);

    });

    app.listen(8888);

  4. One requirement is still missing. We need to log the name to a file if it exists. To do that, we'll need to use the fs library from Node.js. We'll also use the path library to resolve a path to the file we want to write to. First, add the new imports:

    import { promises } from 'fs';

    import { resolve } from 'path';

  5. Now we'll use the promises API from fs to asynchronously write to our log file. Since this is a log, we want to append to it, not overwrite it on each request. We'll use appendFile and write the name along with a newline character. We want this operation to repeat before returning:

      if (name) {

        await promises.appendFile(resolve(__dirname, 'names.txt'), `${name} `);

      }

    That's almost it, but we should have a warning by now that our handler function isn't properly async. All we need to do is add the async keyword to it.

  6. The completed code looks like this:

    import express, { Request, Response } from 'express';

    import { promises } from 'fs';

    import { resolve } from 'path';

    const app = express();

    app.get('/', async (req: Request, res: Response) => {

      const { name } = req.query;

      if (name) {

        await promises.appendFile(resolve(__dirname, 'names.txt'), `${name} `);

      }

      res.send(`Hello ${name ?? 'World'}!`);

    });

    app.listen(8888);

  7. Run the program with npx ts-node express.ts and try hitting the URL at http://localhost:8888?name=your_name a few times. Try hitting that URL with different names and watch your log file increment. Here are a few examples.
  8. The following is the browser output for your_name:
    Figure 13.1: Browser message for name = your_name

    Figure 13.1: Browser message for name = your_name

  9. The following is the browser output for Matt:
    Figure 13.2: Browser message for name = Matt

    Figure 13.2: Browser message for name = Matt

  10. The following is the browser output for Albert Einstein:
Figure 13.3: Browser message for name = Albert Einstein

Figure 13.3: Browser message for name = Albert Einstein

The names.txt file will increment as follows:

Figure 13.4: Log file

Figure 13.4: Log file

Exercise 13.08: NestJS

In contrast to Express, NestJS is a highly opinionated and fully featured framework for building TypeScript applications. NestJS can be used to quickly bootstrap an application. It provides out-of-the-box support for middleware, GraphQL, and Websockets. It ships with ESLint, a dependency injection framework, a test framework, and many other useful things. Some developers really enjoy working with such a full-featured framework and others find all the boilerplate oppressive and prefer to work with something more bare-bones, such as Express:

Note

The code files for this exercise can be found here: https://packt.link/blRq3.

Let's bootstrap a new NestJS application and give it a closer look.

  1. NestJS applications can be generated by a command-line interface (CLI) that can be installed via npm. Install that package globally:

    npm i -g @nestjs/cli

  2. When we use the CLI, it will generate a project by creating a new directory inside the directory we entered the command into, so you may want to change the directory to where you store your projects. Then, generate the project:

    nest new async-nest

    Here the project is named async-nest. You can name it differently. NestJS will automatically install all dependencies and bootstrap a bare-bones application.

  3. Change directory into your new application and start looking at the code. If you pop open main.ts, you'll see async/await already in use. That module will look something like this:

    import { NestFactory } from '@nestjs/core';

    import { AppModule } from './app.module';

    async function bootstrap() {

      const app = await NestFactory.create(AppModule);

      await app.listen(3000);

    }

    bootstrap();

    NestJS is built on top of Express. This code will create a new Express application. The internals of Express are not exposed to you as you write NestJS code, but you always have the option to drop down to them if you need something not supported by NestJS.

    Let's go over a few useful commands that you can start using immediately. If you type npm test (or npm t), you'll launch a test run by the Jest framework. This test launches an instance of your application, invokes it, and then shuts it down after verifying the expected response was received. NestJS ships with fixtures that allow a light version of your app to be tested.

    It's a great idea to continue adding unit and integration tests to your app as you work on it. TypeScript can help you ensure code correctness, but only tests will guarantee that your app is behaving as it should.

    Another useful command is npm run lint. This will check your code style and notify you of any issues with it by using the popular ESLint library.

  4. Finally, you can type npm run start:dev to run the development server in watch mode, which means the server will restart whenever you change a file.
  5. Try running that now and navigate to http://localhost:3000 and you'll see the Hello World message. If you open the file called app.service.ts and change the message returned there, you can just refresh your browser and you should see the message change.

    Now that we've seen this simple Hello World app done in two very different frameworks, let's add the same greeting and logging functionality that we did in Exercise 13.07: async/await in Express.js.

  6. To add the custom greeting based on the query param, let's open two files, app.controller.ts and app.service.ts. Notice that app.service.ts implements a getHello function that returns the string "Hello World!". We will need to change this function to accept a name argument.
  7. Add the name argument with the string type to the function's argument list, and then change the return to a string template and say Hello. You'll have something like this:

    export class AppService {

      getHello(name: string): string {

        return `Hello ${name}!`;

      }

    }

    This is a simple refactor. If we check app.controller.ts, we'll see that our IDE is now telling us that getHello needs an argument and we're not done yet.

    In the Express application, we found our query parameter on the built-in Request object. You could do the same thing in NestJS, but it's more common and preferable to use a decorator. Decorators are special functions that wrap other functions. They are sometimes called higher-order functions and are similar to aspects of languages such as Java.

    The decorator we want to use is @Query, which takes an argument of the name of the query parameter and then binds that parameter to one of our function arguments.

  8. We can import that decorator from @nestjs/common. Then we add the function argument to getHello and pass it through to the service call. One more thing that's a good idea is to set a default so that we maintain backward compatibility and don't print out Hello undefined if we fail to give an argument. Adding the default may prompt a hint that you no longer need the type annotation as it is trivially inferred from the default type. Go ahead and remove it if you like:

    import { Controller, Get, Query } from '@nestjs/common';

    import { AppService } from './app.service';

    @Controller()

    export class AppController {

      constructor(private readonly appService: AppService) {}

      @Get()

      getHello(@Query('name') name: string = 'World'): string {

        return this.appService.getHello(name);

      }

    }

  9. The dev server should restart and now, if we browse to http://localhost:3000/?name=Matt, we'll see Hello Matt!:
    Figure 13.5: Browser message for name = Matt

    Figure 13.5: Browser message for name = Matt

  10. Now let's add the same logging functionality that we implemented in Express.

    In a full-scale application, we'd probably want to build a separate logging service class. For our purposes, we can implement that as a separate async method. Add the method to app.service.ts and call it with await from getHello. Test it to be sure that it's working correctly.

    There are a few gotchas here. One is that NestJS is automatically transpiling and serving your code from a folder called dist, so you'll find your names.txt file in there once you start logging names. But the bigger trick here is that in order to await the logging, we need to make getHello in app.service.ts into an async method. This, in turn, will mean that getHello in app.controller.ts must also be async. What will changing these methods to async do to our app? Nothing! NestJS already knows how to resolve the promises before returning the request.

  11. One more thing to check out in this exercise is the unit test. Since we've set a default value for the name attribute, the test should still work, right? Well actually, it doesn't. Try running npm test and you'll see the problem. The issue is that the test isn't expecting getHello to be async. That's OK. We can fix it by making the test callback async to look like this:

      describe('root', () => {

        it('should return "Hello World!"', async () => {

          expect(await appController.getHello()).toBe('Hello World!');

        });

      });

    The test should now pass. Try adding another test with an argument.

Exercise 13.09: TypeORM

TypeORM is an object relational mapper written in, and for, TypeScript. TypeORM supports many popular databases, such as MySQL, Postgres, SQL Server, SQLite, and even MongoDB and Oracle. TypeORM is often used in NestJS applications, so in this exercise we will add a local in-memory SQLite database to work with our NestJS application.

In this exercise, you will build another REST service to help us keep track of the promises we make. Since Promise is the name of a built-in object in TypeScript, let's use the term "pledge" so we can differentiate domain concepts from language abstractions:

Note

The code files for this exercise can be found here: https://packt.link/ZywYh.

  1. To get started, let's bootstrap a new NestJS project:

    nest new typeorm-nest

  2. NestJS has a powerful module system that lets us build out different functional areas of our application in cohesive chunks. Let's create a new module for pledges:

    nest g module pledge

    This command will generate a new module under the /pledge subdirectory.

  3. We're also going to need a controller and a service for the pledge API, so let's generate those using the NestJS CLI:

    nest g controller pledge

    nest g service pledge

  4. Finally, we need to install the typeorm library, SQLite3, and NestJS integration:

    npm i @nestjs/typeorm sqlite3 typeorm

    TypeORM maps database tables to TypeScript entities by means of decorators on plain objects.

  5. Let's create pledge.entity.ts under /pledge and create our first entity:

    import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

    @Entity()

    export class Pledge {

      @PrimaryGeneratedColumn()

      id: number;

      @Column()

      desc: string;

      @Column()

      kept: boolean;

    }

    For this entity, we're using a few specialized decorators, such as PrimaryGeneratedColumn. These decorators can be very powerful but often rely on underlying database functionality. Because SQLite can generate an ID for our table, TypeORM is able to expose that in a declarative way with a decorator, but if it couldn't, this wouldn't work. It's always good to check the documentation before proceeding with a new implementation.

    Now that we have an entity, we need to provide configuration to TypeORM about what our database is and where to find it, as well as what entities we want to map. For databases such as MySQL and Postgres, this might include a URI as well as database credentials. Since SQLite is a file-based database, we will just provide the name of the file we want to write.

    Note that production database credentials should always be handled safely, and the best practices for doing so are beyond the scope of this book, but suffice to say that they shouldn't be checked into your version control.

  6. Let's configure our application to use SQLite. We want to configure TypeORM at the root of our application, so let's import the module into app.module.ts:

        TypeOrmModule.forRoot({

          type: 'sqlite',

          database: 'db',

          entities: [Pledge],

          synchronize: true,

        }),

  7. Doing this will require a couple of more imports at the top of the module:

    import { TypeOrmModule } from '@nestjs/typeorm';

    import { Pledge } from './pledge/pledge.entity';

    We're letting NestJS know that our application will use a SQLite database and will manage the Pledge entity. By setting synchronize: true, we are telling TypeORM to automatically create any entities that don't already exist in the database when the application starts. This setting should NOT be used in production as it may cause data loss. TypeORM supports migrations for managing databases in production environments, another topic beyond the scope of this book.

  8. If we start our application now with npm run start:dev, it will start up and we'll get a new binary file (the SQLite database) called db.
  9. Before we can use the Pledge entity in our new module, we need to do a little more boilerplate. Open up pledge.module.ts and add an import so that the module looks like this:

    import { Module } from '@nestjs/common';

    import { TypeOrmModule } from '@nestjs/typeorm';

    import { PledgeController } from './pledge.controller';

    import { Pledge } from './pledge.entity';

    import { PledgeService } from './pledge.service';

    @Module({

      controllers: [PledgeController],

      imports: [TypeOrmModule.forFeature([Pledge])],

      providers: [PledgeService],

    })

    export class PledgeModule {}

    This will allow the Pledge entity to be used by pledge.service.ts. Again, NestJS has quite a lot of boilerplate, which may be jarring to developers who are used to unopinionated ExpressJS workflows. This module system can help us to isolate our application into functional areas. It's a good idea to understand the benefits of a structured application before deciding whether a framework such as NestJS is right for your application or team.

    We can now start to build out our Pledge service. TypeORM supports both Active Record, where an entity itself has methods for reading and updating, and Data Mapper, where such functionality is delegated to a Repository object. We will follow the Data Mapper pattern in this exercise.

  10. To start, we will add a constructor to the Pledge service and inject the repository to expose it as a private member of the class. Once we've done that, we can start to access some of the repository methods:

    import { Injectable } from '@nestjs/common';

    import { Pledge } from './pledge.entity';

    import { InjectRepository } from '@nestjs/typeorm';

    import { Repository } from 'typeorm';

    @Injectable()

    export class PledgeService {

      constructor(

        @InjectRepository(Pledge)

        private pledgeRepository: Repository<Pledge>,

      ) {}

      findAll(): Promise<Pledge[]> {

        return this.pledgeRepository.find();

      }

    }

    We've now exposed a findAll method, which will query the database for all the Pledge entities and return them in an array.

  11. In a production application, it can often be a good idea to implement pagination, but this will do for our purposes. Let's implement some other methods:

    import { Injectable } from '@nestjs/common';

    import { InjectRepository } from '@nestjs/typeorm';

    import { DeleteResult, Repository } from 'typeorm';

    import { Pledge } from './pledge.entity';

    @Injectable()

    export class PledgeService {

      constructor(

        @InjectRepository(Pledge)

        private pledgeRepository: Repository<Pledge>,

      ) {}

      delete(id: number): Promise<DeleteResult> {

        return this.pledgeRepository.delete(id);

      }

      findAll(): Promise<Pledge[]> {

        return this.pledgeRepository.find();

      }

      findOne(id: number): Promise<Pledge> {

        return this.pledgeRepository.findOne(id);

      }

      save(pledge: Pledge): Promise<Pledge> {

        return this.pledgeRepository.save(pledge);

      }

    }

    We can get pretty far using just repository methods, which will generate SQL queries for us, but it's also possible to use SQL or a query builder with TypeORM.

  12. Implementing these methods in a service won't expose them to our API, so we need to add matching controller methods in pledge.controller.ts. Each controller method will delegate to a service method and NestJS will take care of gluing all the pieces together:

    import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';

    import { DeleteResult } from 'typeorm';

    import { Pledge } from './pledge.entity';

    import { PledgeService } from './pledge.service';

    @Controller('pledge')

    export class PledgeController {

      constructor(private readonly pledgeService: PledgeService) {}

      @Delete(':id')

      deletePledge(@Param('id') id: number): Promise<DeleteResult> {

        return this.pledgeService.delete(id);

      }

      @Get()

      getPledges(): Promise<Pledge[]> {

        return this.pledgeService.findAll();

      }

      @Get(':id')

      getPledge(@Param('id') id: number): Promise<Pledge> {

        return this.pledgeService.findOne(id);

      }

      @Post()

      savePledge(@Body() pledge: Pledge): Promise<Pledge> {

        return this.pledgeService.save(pledge);

      }

    }

    This controller will automatically inject the service and can then easily map service methods to API endpoints using decorators and dependency injection.

  13. Since we ran our application with npm run start:dev, it should hot reload through all these changes.
  14. Check the console and make sure there are no errors. If our code is correct, we can use a REST client such as Postman to start sending requests to our service. If we send a POST request with a payload such as {"desc":"Always lint your code", "kept": true} to http://localhost:3000/pledge, we'll get back a 201 Created HTTP response. Then we can issue GET requests to http://localhost:3000/pledge and http://localhost:3000/pledge/1 to see our record that was stored in SQLite.

In this exercise, we used NestJS and TypeORM to build a real web API that can create and retrieve records from a SQLite database. Doing this isn't very different from using a real production-grade database such as MySQL or PostgreSQL.

Activity 13.01: Refactoring Chained Promises to Use await

In this activity, we will refactor a function that chains promises together to use await. You are supplied with a starter program that is meant to simulate the creation of DOM elements for a website and render them one after another. In reality, most sites will want to render in parallel, but it's possible that information from one component might inform the rendering of another. It is good enough for example purposes in any case:

Note

The code files for this activity can be found here: https://packt.link/L5r76.

  1. Start by running the program as-is with npx ts-node src/refactor.ts. You'll get each message in sequence.
  2. Now, refactor the renderAll function to use async/await. You shouldn't have to touch any other parts of the code to make this work. When your refactoring is complete, run the program again and verify that the output hasn't changed.

The code for the starter program (refactor.ts) is as follows:

export class El {

  constructor(private name: string) {}

  render = () => {

    return new Promise((resolve) =>

      setTimeout(

        () => resolve(`${this.name} is resolved`),

        Math.random() * 1000

      )

    );

  };

}

const e1 = new El('header');

const e2 = new El('body');

const e3 = new El('footer');

const renderAll = () => {

  e1.render().then((msg1) => {

    console.log(msg1);

    e2.render().then((msg2) => {

      console.log(msg2);

      e3.render().then((msg3) => {

        console.log(msg3);

      });

    });

  });

};

renderAll();

Once you run the program, you should get the following output:

header is resolved

body is resolved

footer is resolved

Note

The solution to this activity can be found via this link.

Summary

Asynchronous programming has come a long way in the past 10 years and the introduction of async/await has continued to move it forward. Although not perfect for every use case, this syntactic sugar has proven very popular with the TypeScript community and has gained widespread acceptance in popular libraries and frameworks.

In this chapter, we went over async/await syntax, how it came to be part of the language, and how the use of this syntax is actually complimentary to promises. We then toured several popular frameworks in use by TypeScript developers to see how application developers use promises and asynchronous programming to develop powerful web applications.

This concludes this book's study of language features. The next chapter will look at React for building user interfaces using TypeScript.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset