Page Content

Tutorials

Why use Async programming in TypeScript?

Async programming in TypeScript

In order for code to manage tasks like file operations or network requests without preventing the main thread from running, asynchronous (Async) programming is essential in contemporary JavaScript engines. Reliability and complexity management are the main issues with typical async code, especially when callback techniques are used. Boilerplate code that is required merely to provide appropriate error handling throughout consecutive asynchronous processes frequently results from this.

By introducing a method for integrating synchronous error handling into callback or asynchronous code, the Promise class offers a solution. A promise is a value that might be accessible at any time, in the future, or never.

One of the following three states contains a promise:

  1. Pending: The starting state, in which the procedure is not yet complete.
  2. Fulfilled (or resolved): A value is attached to the Promise as a result of the successful completion of the operation.
  3. Rejected: An error object is stored in the Promise and the operation failed.

The constructor, which takes an executor function as an argument with resolve and reject methods that determine the Promise’s final state, is used when you want to build a new Promise:

const promise = new Promise((resolve, reject) => {
    // Logic that performs the asynchronous work
    resolve(123); // Fulfills the promise with a value
});

Promises and Generics for Result Type

TypeScript offers significant benefits when working with Promises because it understands the flow of values through a promise chain and supports generics (<T>) to define the expected type of the resolved value.

A promise is typed as Promise<T>, where T is the type of the value the promise will resolve with. This explicit typing eliminates reliance on less safe alternatives like Promise<any>. TypeScript’s compiler can infer the return type of an asynchronous function or method that returns a Promise, making the codebase safer and more predictable.

For instance, if a function is designed to return a resolved string after a delay, it is defined to return a Promise<string>:

function iReturnPromiseAfter1Second(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => resolve("Hello world!"), 1000);
    });
}

When this function is used in a chain, TypeScript ensures that subsequent consumers receive the correctly inferred type:

Promise.resolve(123)
.then((res) => {
    // res is inferred to be of type `number`
    return iReturnPromiseAfter1Second(); // Returns `Promise<string>`
})
.then((res) => {
    // res is inferred to be of type `string` 
    console.log(res); 
});

// Output (after 1 second):
// Hello world!

Sequencing

The true power of Promises lies in their chain-ability, which is managed primarily through the .then and .catch methods.

The .then method is used to subscribe to a Promise’s fulfilled fate. If you return a non-Promise value from a .then block, that value is passed directly to the next .then call in the chain. Crucially, if you return a new Promise from a .then block, the chain pauses, and the next .then is only executed once that returned Promise resolves, extracting its internal resolved value.

Error Handling

It is the Promise’s rejected fate that the .catch method subscribes to. It lets you use a single handler to handle errors for any part of the chain that comes before it. If there is a rejection anywhere upstream, the execution immediately moves to the closest tailing .catch and bypasses all intermediate .then calls.

The delivered Promise will also automatically fail if any synchronous errors are thrown inside a .then or .catch block; this means that the error will be handled by the next available .catch handler. This behaviour makes sure that unexpected synchronous runtime faults and asynchronous rejections are handled uniformly.

By successfully returning a new Promise, a .catch handler restarts the chain and permits successful .then handlers to execute, possibly fixing the error:

// Example demonstrating error aggregation and recovery:
Promise.reject(new Error('something bad happened')) // Rejected promise
.then((res) => {
    console.log(res); // not called 
    return 456;
})
.catch((err) => {
    console.log(err.message); // something bad happened
    return 123; // Recovery value starts a new fulfilled promise 
})
.then((res) => {
    console.log(res); // 123 
});

// Output:
// something bad happened
// 123

Generators and Iterators

Iterators

An iterator is a behavioural design pattern that outlines the process for extracting values from an object’s associated collection or sequence.

The basic user interface of an iterator is as follows:


interface Iterator<T> {
    next(value?: any): IteratorResult<T>;
    return?(value?: any): IteratorResult<T>;
    throw?(e?: any): IteratorResult<T>;
}

The result of calling next is an IteratorResult, which is a simple pair indicating if the sequence is finished (done: boolean) and the current value (value: T).

interface IteratorResult<T> {
    done: boolean;
    value: T;
}

Generators

To define a Generator function, use the function* syntax. A generator object is returned by calling a generator function. With its next, return, and throw methods, this object complies with the iterator interface.

Generator functions have two main motivations:

  1. Lazy Iterators: On demand, generators can produce sequences that produce values, such an endless string of numbers.
  2. Externally Controlled Execution: This feature, which enables a function to pause its execution and transfer control to an external caller, is the more fascinating one.

When a generator function encounters the yield keyword, execution pauses. Only when the generator object’s next() is called does the function start running.

Code Example: Externally Controlled Execution

function* generator(){
    console.log('Execution started');
    yield 0;
    console.log('Execution resumed');
    yield 1;
    console.log('Execution resumed');
}

var iterator = generator();
console.log('Starting iteration');
console.log(iterator.next()); 
console.log(iterator.next()); 
console.log(iterator.next()); 

Output:

Starting iteration
Execution started
{ value: 0, done: false }
Execution resumed
{ value: 1, done: false }
Execution resumed
{ value: undefined, done: true }

Additionally, generators can communicate in both directions. The iterator caller, an external system, can affect the generator’s state by:

  • Using iterator.next(valueToInject), pushing a value into the generator body. The result of the yield expression is this inserted value.
  • At the point of suspension, iterator.throw(error) is used to throw an exception into the generator body.

What exactly enables the async/await syntax is the ability to pause, resume, inject values, and inject errors.

Syntax for Working with Promises

Promises and Generators serve as the foundation for the language-level syntactic sugar known as async/await syntax. Working with asynchronous activities is made easier and more readable by this method, which makes them look nearly equivalent to synchronous code flow.

The Keyword

It is necessary to use the async keyword before an asynchronous function. Every async function yields a Promise. A resolved Promise encapsulates the value that the function returns if it does so explicitly. If an exception is thrown, a rejected Promise is used to package the exception.

The await keyword can only be used inside an async function. When await is used on a Promise, it suspends the execution of the async function until that Promise is settled (either fulfilled or rejected).

  • If the Promise is fulfilled, await unwraps the value from the Promise<T> and returns that value (of type T).
  • In the event that the Promise is refused, await throws an error synchronously at that line, which can then be captured with a typical try/catch block.

Error handling in this synchronous fashion is similar to how exceptions are handled in standard synchronous programs.

Code Example: in Action

The example below uses a delay function which returns a Promise<number> after a specified time. Notice how await is used within the dramaticWelcome function to pause execution and retrieve the number result synchronously:

function delay(milliseconds: number, count: number): Promise<number> {
    return new Promise<number>(resolve => {
        setTimeout(() => {
            resolve(count);
        }, milliseconds);
    });
}

// async function always returns a Promise 
async function dramaticWelcome(): Promise<void> {
    console.log("Hello");
    for (let i = 0; i < 5; i++) {
        // await is converting Promise<number> into number 
        const count:number = await delay(500, i);
        console.log(count);
    }
    console.log("World!");
}

dramaticWelcome();

Output (Printed sequentially with 500ms gaps):

Hello
0
1
2
3
4
World!

The synchronous look that async/await achieves is demonstrated by the code’s flow.

Underlying Implementation

Async/await functions because of generators. While using the syntax does not need an understanding of this mechanism, TypeScript and JavaScript implement async/await by converting the code into a generator function that is encapsulated in a unique handler.

The produced generator function essentially substitutes yield for the await keyword. Managing the generator’s execution is done by the wrapping handler, which waits for the Promise to settle before calling generator.next(result) (on fulfilment) or generator.throw(error) (on rejection) to resume execution, inject the result, or inject the error, respectively. This specifically makes advantage of the three functions that generators offer: pause, inject a value, and inject an exception.

For example, when targeting ES6, the dramaticWelcome function is compiled to use a generator function (function*) internally:

function dramaticWelcome() {
    return __awaiter(this, void 0, void 0, function* () {
        console.log("Hello");
        for (let i = 0; i < 5; i++) {
            // await is converting Promise<number> into number
            const count = yield delay(500, i); // <-- 'await' becomes 'yield'
            console.log(count);
        }
        console.log("World!");
    });
}

This demonstrates how generators’ strong, low-level control flow capabilities are key to the high-level async/await syntax. Since version 1.7 (for ES6 targets), TypeScript has supported async/await; starting with version 2.1, it will support ES3/ES5 targets as long as a Promise polyfill is available.

Index