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:
- Pending: The starting state, in which the procedure is not yet complete.
- Fulfilled (or resolved): A value is attached to the Promise as a result of the successful completion of the operation.
- 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:
- Lazy Iterators: On demand, generators can produce sequences that produce values, such an endless string of numbers.
- 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 theyield
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 thePromise<T>
and returns that value (of typeT
). - In the event that the Promise is refused,
await
throws an error synchronously at that line, which can then be captured with a typicaltry/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.