
This post is a personal summary after reading Multiparadigm Programming (by Yoo In-dong).
Handling Asynchrony as Values
Promise is both an object and a protocol that allows you to handle the results of asynchronous operations as values. When combined with the iterator pattern, it can create a very powerful asynchronous programming model. Let's explore how to control asynchronous logic using list processing.
Promise
Promises allow you to create objects immediately and handle them as values regardless of whether the asynchronous operation has completed, and you can retrieve results or handle errors when needed.
They have three states: pending, fulfilled, and rejected, and multiple Promises can be combined to execute sequentially or simultaneously.
The delay function that returns a Promise
function delay<T>(time: number, value: T): Promise<T> {
return new Promise((resolve) => setTimeout(resolve, time, value))
}
This is a function that waits for the specified time and then returns the value received.
Have you ever used new Promise() directly?
Recently, it's rare to use new Promise()
directly because Web APIs and most libraries already provide Promise-based interfaces.
However, when implementing unique parallel execution control or complex asynchronous logic not provided by existing tools, logic that directly creates and controls through new Promise()
is necessary.
Being proficient with new Promise()
means having a deep understanding of asynchronous control and problem-solving abilities.
Promise.race
Promise.race
returns the result or error of the first Promise to complete among multiple Promises executed in parallel.
const promise1 = new Promise((resolve) => {
setTimeout(() => resolve('Promise 1 complete!'), 2000);
});
const promise2 = new Promise((resolve) => {
setTimeout(() => resolve('Promise 2 complete!'), 1000);
});
Promise.race([promise1, promise2])
.then((result) => {
console.log(result); // 'Promise 2 complete!'
});
In what situations can Promise.race
be used in practice?
Setting timeouts for IO operations
If you want to handle errors when API responses are delayed, using Promise.race
is a good approach.
const result = await Promise.race([
fetch('/friends'),
delay(5000).then(() => { throw new Error('Timeout!') })
]);
While you could also use AbortController with fetch, the method using Promise.race
provides versatility for effectively handling asynchronous operations in various situations beyond fetch.
Rendering UI with different strategies based on response speed
In a screen where there's an 'Invite Friends' button in a chat room, and you fetch the friend list when the button is pressed, you can use different UI strategies depending on response time. If the friend list API response completes within 100ms, render immediately; otherwise, display a loading indicator. This can be easily implemented using Promise.race
.
UI rendering quiz using Promise
Multi-paradigm Programming Chapter 4 Quiz
Promise.all
Promise.all
waits until all given Promises are fulfilled and returns all results as an array. If even one is rejected, it immediately rejects and returns the reason. It's useful when executing multiple asynchronous operations in parallel and waiting for all tasks.
function getFile(name: string, size = 1000): Promise<File> {
return delay(size, { name, body: '...', size });
}
const files = await Promise.all([
getFile('img.png', 500),
getFile('book.pdf', 1000),
getFile('index.html', 1500)
]);
console.log(files);
// After about 1,500ms:
// [
// { name: 'img.png', body: '...', size: 500 },
// { name: 'book.pdf', body: '...', size: 1000 },
// { name: 'index.html', body: '...', size: 1500 }
// ]
Since it executes in parallel, the result is completed after 1500ms, which is the time of the longest Promise.
If there's a Promise that rejects in the middle, it terminates immediately. Therefore, the following code executes the catch block after 500ms:
try {
const files = await Promise.all([
getFile('img.png'), // default size: 1000, delay: 1000ms
getFile('book.pdf'),
getFile('index.html'),
delay(500, Promise.reject('File download failed'))
]);
console.log(files);
} catch (error) {
// After about 500ms:
console.error(error); // 'File download failed'
}
Promise.allSettled
Promise.allSettled
waits for all Promises to complete regardless of fulfillment or rejection, and then returns the results as objects. This function appeared in ES11, and previously, helper functions like the following were created and used:
const settlePromise = <T>(promise: Promise<T>) =>
promise
.then(value => ({ status: 'fulfilled', value }))
.catch(reason => ({ status: 'rejected', reason }));
const files = await Promise.all([
getFile('img.png'),
getFile('book.pdf'),
getFile('index.html'),
Promise.reject('File download failed')
].map(settlePromise));
Promise.any
Promise.any
returns the value of the first Promise to be fulfilled among multiple Promises. It returns the result value of the first fulfilled Promise even if there are rejected values.
const files = await Promise.any([
getFile('img.png', 1500),
getFile('book.pdf', 700),
getFile('index.html', 900),
delay(100, Promise.reject('File download failed'))
]);
console.log(files);
// After about 700ms
// { name: 'book.pdf', body: '...', size: 700 }
Handling Asynchrony with Laziness
Now let's expand our thinking of treating Promises as values by creating reusable functions for handling asynchronous situations.
How to delay Promise execution
Promise.all
executes all Promises in parallel and returns all results as an array. In this situation, how can we control the load for each Promise? For example, instead of executing all 6 Promises at once, we might want to execute them in 2 batches of 3.
Let's create a function that divides and executes such asynchronous operations:
async function executeWithLimit<T>(
promises: Promise<T>[],
limit: number
): Promise<T[]> {
const result1 = await Promise.all([promises[0], promises[1], promises[2]]);
const result2 = await Promise.all([promises[3], promises[4], promises[5]]);
return [
...result1,
...result2
];
}
console.time("executeWithLimit");
await executeWithLimit(
[
delay(1000, "test"),
delay(500, "test"),
delay(300, "test"),
delay(1000, "test"),
delay(500, "test"),
delay(200, "test"),
],
3,
);
console.timeEnd("executeWithLimit"); // executeWithLimit: 1001.912841796875 ms
We expected it to take about 2000ms, with each batch taking up to 1000ms, but it actually only took 1000ms. This is because Promise objects are executed immediately upon creation.
Immediate execution of Promises
Promise objects are executed immediately upon creation. That is, the moment the delay
function is called, the Promise has already started. Therefore, even if we group them into 3 and wait with await
, all Promises start simultaneously.
The meaning of parallel execution
Promise.all
is just a function that receives all already-executed Promises, waits until they are all completed, and then returns each Promise as an array; it's not a function that controls the start of the Promises themselves. Even if there are two calls to Promise.all
, each Promise has already started, so it doesn't affect the total execution time.
In the end, even though it looks like we're executing in groups of 3 in parallel, they're actually all starting at the same time.
Solution
The solution is simple. Just wrap each Promise in a function:
async function executeWithLimit<T>(
fs: (() => Promise<T>)[],
limit: number,
): Promise<T[]> {
const result1 = await Promise.all([fs[0](), fs[1](), fs[2]()]);
const result2 = await Promise.all([fs[3](), fs[4](), fs[5]()]);
return [...result1, ...result2];
}
(async () => {
console.time("executeWithLimit");
await executeWithLimit(
[
() => delay(1000, "test"),
() => delay(500, "test"),
() => delay(300, "test"),
() => delay(1000, "test"),
() => delay(500, "test"),
() => delay(200, "test"),
],
3,
);
console.timeEnd("executeWithLimit"); // executeWithLimit: 2003.93701171875 ms
})();
This way, we can delay the execution of Promises so they run only when needed.
Claude (Sonnet 4)'s imperative implementation of a concurrency handling function
I asked Claude to implement the executeWithLimit
function:
async function executeWithLimit<T>(
promiseFactories: (() => Promise<T>)[],
limit: number,
): Promise<T[]> {
const results: T[] = [];
// Divide the promiseFactories array into chunks of size limit
for (let i = 0; i < promiseFactories.length; i += limit) {
const chunk = promiseFactories.slice(i, i + limit);
// Create promises by executing all promise factories in the current chunk
const promises = chunk.map((factory) => factory());
// Execute the created promises in parallel
const chunkResults = await Promise.all(promises);
// Add the results to the overall results array
results.push(...chunkResults);
}
return results;
}
Functional implementation of a concurrency handling function
Let's implement executeWithLimit
from a list processing perspective.
chunk(size, iterable) function
First, we need a list processing function that groups an iterable into the given size:
function* chunk<T>(size: number, iterable: Iterable<T>): IterableIterator<T[]> {
const iterator = iterable[Symbol.iterator]();
while (true) {
const arr = [
...take(size, {
[Symbol.iterator]() {
return iterator;
},
}),
];
if (arr.length) yield arr;
if (arr.length < size) break;
}
}
class FxIterable<A> {
...
chunk(size: number) {
return fx(chunk(size, this));
}
}
console.log([...chunk(2, [1, 2, 3, 4, 5])]); // [[1, 2], [3, 4], [5]]
Completing executeWithLimit starting with chunk
async function fromAsync<T>(
iterable: Iterable<Promise<T>> | AsyncIterable<T>
): Promise<T[]> {
const arr: T[] = [];
for await (const a of iterable) {
arr.push(a);
}
return arr;
}
const executeWithLimit = <T>(fs: (() => Promise<T>)[], limit: number): Promise<T[]> =>
fx(fs)
.chunk(limit) // Group by limit
.map(fs => fs.map(f => f())) // Execute async functions
.map(ps => Promise.all(ps)) // Wait for limit at a time
.to(fromAsync) // Extract results from Promise.all
.then(arr => arr.flat()); // Flatten to 1D array
The key to the executeWithLimit
function is laziness:
- The
executeWithLimit
function takes functions that delay Promise execution as arguments. .map(fs => fs.map(f => f()))
looks like it executes all the functions, but unlike Array's map, it's our custom map that is lazily evaluated.- Therefore, in
fromAsync
, only the functions in that chunk are executed when one element is extracted, and in the nextmap
, they are wrapped withPromise.all
. fromAsync
waits for the results ofPromise.all
by extracting them withfor await...of
.- As a result, it evaluates delayed asynchronous operations sequentially and stores them in an array.
Making the code more concise with better use of laziness in function composition
const executeWithLimit = <T>(fs: (() => Promise<T>)[], limit: number): Promise<T[]> =>
fx(fs)
.map(f => f()) // Lazy execution of async functions
.chunk(limit) // Grouping
.map(ps => Promise.all(ps)) // Wrap with Promise.all to wait for limit at a time
.to(fromAsync)
.then(arr => arr.flat());
.to(fromAsync)
- This is where actual execution happens!
// for await loop executes sequentially:
// Step 1: Execute first Promise.all
await Promise.all([f1(), f2(), f3()])
// → API calls 1,2,3 simultaneously → wait for all to complete
// Result: [response1, response2, response3]
// Step 2: Execute second Promise.all
await Promise.all([f4(), f5(), f6()])
// → API calls 4,5,6 simultaneously → wait for all to complete
// Result: [response4, response5, response6]
// Step 3: Execute third Promise.all
await Promise.all([f7()])
// → API call 7 → wait for completion
// Result: [response7]
// Final result from fromAsync
[
[response1, response2, response3],
[response4, response5, response6],
[response7]
]
Practicing handling Promises as values using lazy evaluation and list processing will help you develop the ability to handle various asynchronous situations well (especially in backend development).
Lazy evaluation is not just a tool for performance improvement or optimization, but through code patterns that evaluate iterators and first-class functions at desired points, you can create reusable logic.
Handling Asynchrony with Types
AsyncIterator, AsyncIterable, AsyncGenerator
JavaScript provides protocols such as AsyncIterator, AsyncIterable, and AsyncGenerator to support sequential processing of asynchronous operations.
AsyncIterator, AsyncIterable interfaces
The following code expresses the structure of AsyncIterator, AsyncIterable, and AsyncGenerator through TypeScript interface definitions, simplified from the actual implementation:
// Means the AsyncIterator is not yet complete
interface IteratorYieldResult<T> {
done?: false;
value: T;
}
// Means the AsyncIterator is complete
interface IteratorReturnResult {
done: true;
value: undefined;
}
// An interface with a next method that returns a Promise
interface AsyncIterator<T> {
next(): Promise<IteratorYieldResult<T> | IteratorReturnResult>;
}
interface AsyncIterable<T> {
[Symbol.asyncIterator](): AsyncIterator<T>;
}
interface AsyncIterableIterator<T> extends AsyncIterator<T> {
[Symbol.asyncIterator](): AsyncIterableIterator<T>;
}
Just as Iterables can be iterated with the for...of
statement, AsyncIterables can be iterated asynchronously using the for await...of
statement.
Basic AsyncGenerator syntax
AsyncGenerator provides functionality to generate values asynchronously and process them sequentially. The stringsAsyncTest
shown in the example below is an AsyncGenerator that asynchronously generates strings:
async function* stringsAsyncTest(): AsyncIterableIterator<string> {
yield delay(1000, 'a');
const b = await delay(500, 'b') + 'c';
yield b;
}
async function test() {
const asyncIterator: AsyncIterableIterator<string> = stringsAsyncTest();
const result1 = await asyncIterator.next();
console.log(result1.value); // 'a' after about 1000ms
const result2 = await asyncIterator.next();
console.log(result2.value); // 'bc' after about 500ms
const { done } = await asyncIterator.next();
console.log(done); // true
}
await test();
}
toAsync function
The toAsync
function converts a synchronous Iterable or an Iterable containing Promises into an AsyncIterable. Let's implement it in two ways:
-
Directly implementing AsyncIterator:
typescriptfunction toAsync<T>(iterable: Iterable<T | Promise<T>>): AsyncIterable<Awaited<T>> { return { [Symbol.asyncIterator](): AsyncIterator<Awaited<T>> { const iterator = iterable[Symbol.iterator](); return { async next() { const { done, value } = iterator.next(); return done ? { done, value } : { done, value: await value }; } }; } }; }
-
Implementing using AsyncGenerator:
typescriptasync function* toAsync<T>( iterable: Iterable<T | Promise<T>> ): AsyncIterableIterator<Awaited<T>> { for await (const value of iterable) { yield value; } }
For toAsync
, the AsyncGenerator approach is simpler, shorter, and more intuitive. In this case, the imperative approach is the most suitable among various paradigms, and choosing the appropriate paradigm for each problem provides better code writing and higher maintainability.
Higher-order functions for handling AsyncIterable
Let's create mapAsync
, an AsyncIterable-handling version of map
.
Using a generator, it can be implemented concisely as follows:
async function* mapAsync<A, B>(
f: (a: A) => B,
asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<Awaited<B>> {
for await (const value of asyncIterable) {
yield f(value);
}
}
Now let's create filterAsync
, an AsyncIterable-handling version of filter
:
async function* filterAsync<A>(
f: (a: A) => boolean | Promise<boolean>,
asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<A> {
for await (const value of asyncIterable) {
if (await f(value)) {
yield value;
}
}
}
Protocol for making functions support both synchronous and asynchronous - toAsync
The toAsync
function is a function that converts a regular Iterable or an Iterable composed of Promise values into an AsyncIterable.
map function supporting both synchronous and asynchronous
The existing map takes Iterable<A>
as an argument, and mapAsync takes AsyncIterable<A>
as an argument.
In TypeScript, one function can perform two or more roles through function overloading. Let's apply it right away:
Rename the existing map to mapSync, and replace it with a function called map that supports both mapSync and mapAsync.
function map<A, B>(
f: (a: A) => B,
iterable: Iterable<A>
): IterableIterator<B>;
function map<A, B>(
f: (a: A) => B,
asyncIterable: AsyncIterable<A>
): AsyncIterableIterator<Awaited<B>>;
function map<A, B>(
f: (a: A) => B,
iterable: Iterable<A> | AsyncIterable<A>
): IterableIterator<B> | AsyncIterableIterator<Awaited<B>> {
return isIterable(iterable)
? mapSync(f, iterable) // [iterable: Iterable<A>]
: mapAsync(f, iterable); // [iterable: AsyncIterable<A>]
}
This code actively utilizes TypeScript's overloading and type inference:
async function test() {
// Synchronous array processing: mapSync
console.log([...map(a => a * 10, [1, 2])]);
// [10, 20]
// Asynchronous iterable processing: mapAsync
for await (const a of map(a => delay(100, a * 10), toAsync([1, 2]))) {
console.log(a);
}
// Convert asynchronous iterable to array
console.log(
await fromAsync(map(a => delay(100, a * 10), toAsync([1, 2])))
);
// Process synchronous array asynchronously
console.log(
await Promise.all(map(a => delay(100, a * 10), [1, 2]))
);
}
Asynchronous Error Handling
Due to the nature of asynchronous logic, it can be difficult to clearly identify where an error occurred and was executed. Therefore, effective error handling in asynchronous programming is essential.
Loading multiple images and calculating their heights
Let's say we're creating a feature that receives multiple image URLs, calculates the height of each image, and returns the sum. Here's a function that loads images asynchronously:
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.src = url;
image.onload = function() {
resolve(image);
}
image.onerror = function() {
reject(new Error(`load error : ${url}`));
}
});
}
async function calcTotalHeight2(urls: string[]) {
try {
const totalHeight = await urls
.map(async (url) => {
const img = await loadImage(url);
return img.height;
})
.reduce(
async (a, b) => await a + await b,
Promise.resolve(0)
);
return totalHeight;
} catch (e) {
console.error('error: ', e);
}
}
This is a function that receives an array of URLs, loads them asynchronously, calculates the heights, and returns the total. If an error occurs, it's handled in the catch block. Although the code seems to work well, it has the following problems:
- Unnecessary load: Even if an error occurs, it attempts to download all images for the remaining URLs.
- Side effects: While this situation is simply a GET request, if you make POST or PUT requests in the same way, unnecessary requests can cause side effects.
This is code that may cause unnecessary requests or workflow progression due to insufficient consideration of error handling without a deep understanding of Promises and asynchronous situations.
Improved asynchronous logic
Let's make it stop requests immediately when an error occurs and prevent additional load:
async function calcTotalHeight(urls: string[]) {
try {
const totalHeight = await fx(urls)
.toAsync()
.map(loadImage)
.map(img => img.height)
.reduce((a, b) => a + b, 0);
return totalHeight;
} catch (e) {
console.error('error: ', e);
}
}
This approach processes one by one sequentially, so if an error occurs in the middle, it propagates and stops the remaining requests.
The key is to ensure errors occur properly
In asynchronous programming, what's important is not simply handling errors but designing so that errors occur properly. When you have some logic, instead of handling errors within that logic, let the caller detect and handle errors:
try {
const height = await getTotalHeight(urls);
// ...
} catch (e) {
console.error(e);
}
This approach is advantageous for writing pure functions and managing side effects. Error handling is most effective when written close to the context where errors occur.
Also, letting the caller handle errors allows each caller to handle them according to their specific situation, providing flexibility.
Hiding errors is not always the best approach. Completely hiding errors makes it difficult to identify the cause of problems and can lead to unexpected behavior.