
This post is a personal summary after reading Multi-paradigm Programming (by Indong Yoo).
FxTS Library
FxTS is a functional library developed under the leadership of Korean developer Hyunwoo Jo. This library solves complex data processing problems in real-world business environments based on the Iterable/AsyncIterable protocol.
It strongly supports various list processing functions, asynchronous, parallel, and concurrent programming, making it useful in practical work environments. Additionally, all the functions implemented in the Multi-paradigm Programming examples are also implemented in this library.
pipe Function
The pipe
function is a function that composes functions from left to right. pipe | FxTS The first argument can be any value, and the remaining arguments must be unary functions (functions that take only one argument).
const result = pipe(
10,
a => a + 4, // a = 10
a => a / 2, // a = 14
);
console.log(result); // 7
Using with Currying
pipe
provides powerful type inference when combined with functions that support currying.
Providing Flexible Code Structure
pipe
provides more flexible code structure than chaining. While chaining is typically extended through class methods, pipe
allows free combination of functions not provided by the library or user-defined logic.
pipe(
['1', '2', '3', '4', '5'],
map(a => parseInt(a)), // [a: string]
filter(a => a % 2 === 1), // [a: number]
forEach(console.log),
);
console.log
is not a function provided by the library, but it can be naturally combined and used. In this way, pipe
can flexibly integrate regular functions regardless of the library.
Asynchronous functions can also be naturally combined.
await pipe(
Promise.resolve(1),
(a /*: Awaited<number>*/) => a + 1,
async (b /*: Awaited<number>*/) => b + 1,
(c /*: Awaited<number>*/) => c + 1,
); // 4
In this way, the pipe
function can naturally combine synchronous functions, asynchronous functions, iterable-based list processing, curried functions, and functions outside the library.
fx Function (Method Chaining)
In FxTS, you can process Iterable/AsyncIterable not only with the pipe
function but also in a method chaining style. Method Chaining | FxTS You can use the fx
function to chain methods.
fx([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
.filter((a) => a % 2 === 0) // [0, 2, 4, 6, 8]
.map((a) => a * a) // [0, 4, 16, 36, 64]
.take(2) // [0, 4]
.reduce(sum); // 4
Lazy Evaluation
fx
uses lazy evaluation by default, so it's not actually evaluated until strict evaluation methods like toArray
, groupBy
, indexBy
, some
are executed.
In other words, even if you chain methods, they are not executed immediately but are processed all at once when the value is finally needed.
zip Function
The zip
function is a function that combines values from multiple arrays by bundling them at the same position. It can be used when there are separate data sources that are coordinated through array indices.
const keys = ['name', 'job', 'location'];
const values = ['Marty', 'Programmer', 'New York'];
const iterator = zip(keys, values);
console.log(iterator.next()); // { done: false, value: [ 'name', 'Marty' ] }
console.log(iterator.next()); // { done: false, value: [ 'job', 'Programmer' ] }
console.log(iterator.next()); // { done: false, value: [ 'location', 'New York' ] }
console.log(iterator.next()); // { done: true, value: undefined }
const obj = Object.fromEntries(zip(keys, values));
console.log(obj);
// { name: 'Marty', job: 'Programmer', location: 'New York' }
range Function
The range
function returns an Iterable/AsyncIterable of numbers progressing from the start value to the end value (not including the end value). If the start value is not set, it starts from 0.
pipe(
range(4),
toArray,
); // [0, 1, 2, 3]
// Calculate squares from 1 to 10
pipe(
range(1, 11),
map(x => x * x),
toArray,
); // [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
// Generate test data
pipe(
range(5),
map(i => ({ id: i, name: `User${i}` })),
toArray,
);
// [
// { id: 0, name: 'User0' },
// { id: 1, name: 'User1' },
// { id: 2, name: 'User2' },
// { id: 3, name: 'User3' },
// { id: 4, name: 'User4' }
// ]
Through lazy evaluation, numbers are called only when needed, so they can also be used when dynamically generating numbers to match variable array lengths.
const strings = ['a', 'b', 'c', 'd', 'e'];
const iter2 = zip(range(Infinity), strings);
for (const a of iter2) {
console.log(a);
}
// [0, 'a']
// [1, 'b']
// [2, 'c']
// [3, 'd']
// [4, 'e']
In this way, using zip
and range
together allows you to flexibly generate and map indices regardless of array length.
Instead of using loops or imperative code, you can solve problems declaratively, improving code readability and flexibility.
Functions that Replace break
In imperative code, break
is used to reduce unnecessary repetition in loops, lower time complexity, and improve efficiency.
In functional programming, there are functions that play a similar role, such as take
, find
, some
, every
, head
, etc.
take
is a function that limits results by specifying the maximum number of items to consume from a lazily evaluated iterator. In other words, take
is a function that reduces time complexity based on a numerical value.
const iter = take(2, [0, 1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 0}
iter.next() // {done:false, value: 1}
iter.next() // {done:true, value: undefined}
// Preview 3 search results
pipe(
searchResults,
take(3),
map(item => item.title),
toArray,
);
What if you want to limit results based on a condition rather than a number? You can use functions like takeWhile
and takeUntilInclusive
.
takeWhile Function
The takeWhile
function returns an Iterable/AsyncIterable that continues to take values as long as the given condition function f
is satisfied.
Unlike take
, it is based on a condition rather than a count. It stops when the condition becomes false
.
const iter = takeWhile(a => a < 3, [1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 1} // true because 1 < 3
iter.next() // {done:false, value: 2} // true because 2 < 3
iter.next() // {done:true, value: undefined} // stops because 3 < 3 is false
takeUntilInclusive Function
The takeUntilInclusive
function returns an Iterable/AsyncIterable that takes values until the given condition function f
returns a truthy value.
const iter = takeUntilInclusive(a => a % 2 === 0, [1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 1} // false because 1 % 2 === 0
iter.next() // {done:false, value: 2} // true because 2 % 2 === 0, but inclusive so 2 is included
iter.next() // {done:true, value: undefined} // stops because the condition became true
The important thing is that it takes values including the element for which the condition becomes true.
Backend Asynchronous Programming
Let's solve common problems using functional style and list processing. In backend environments, asynchronous situations frequently occur, making it important to efficiently utilize resources and reduce time through parallelism.
Maintaining Stable Asynchronous Operation Intervals
Let's say we need to implement a specific function to be executed at regular time intervals. Using list processing, we can handle it as follows:
await fx(range(Infinity))
.toAsync()
.forEach(() => Promise.all([
syncPayments(),
delay(10000)
]));
This code executes the syncPayments
function at 10-second intervals. Looking at the code in detail:
- Using
range(Infinity)
for an infinite iterable- Since it's a repetitive task with an unknown end, an infinite iterable is used.
toAsync
is used to transition to asynchronous mode.
- Using
forEach
for repeated execution- It executes the task as it iterates through the iterable (in this case, infinitely).
- Concurrent execution with
Promise.all
- Promise.all waits for both tasks to complete.
- Therefore, it waits at least 10 seconds due to the
delay
function.
Effectively Handling Maximum Request Size Limits
In backend systems, request size limits are often imposed in communication between services. For example, if a specific function is limited to processing 5 requests at a time, how should we handle it?
You can safely make requests by dividing them into chunks of N using chunk
.
fx(payments)
.map(p => p.store_order_id)
.chunk(5)
.toAsync()
.flatMap(StoreDB.getOrders)
.toArray();
Improving Efficiency with Parallelism
If you know how many pages to request, you don't necessarily need to request all pages sequentially.
The FxTS library provides a concurrent
method for concurrency handling.
await pipe(
[1, 2, 3, 4, 5, 6],
toAsync,
map((a) => delay(1000, a)),
concurrent(3),
each(console.log), // log 1, 2, 3, 4, 5, 6
); // 2 seconds
// evaluation
// ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
// │ 1 │──│ 2 │──│ 3 │──│ 4 │──│ 5 │──│ 6 │
// └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
// map │ │ │ │ │ │
// concurrent(3) (1) (1) (1) (2) (2) (2)
// │ │ │ │ │ │
// ▼ ▼ ▼ ▼ ▼ ▼
List Processing Patterns
Let's look at some patterned examples to understand various combinations of list processing more structurally.
Through the examples in this section, let's better remember list processing techniques and use them effectively when needed.
Map-Reduce Pattern
One of the most widely used patterns, it transforms an iterable with map
and then accumulates it with reduce
to derive the final result. It is mainly used when the result is a single value.
Converting Query String to Object
const queryString = "name=Sanghyeon%20Lee&gender=male&city=Seoul";
const queryObject = queryString
.split("&")
.map((param) => param.split("="))
.map(entry => entry.map(decodeURIComponent))
.map(([key, val]) => ({ [key]: val }))
.reduce((a, b) => Object.assign(a, b), {});
console.log(queryObject);
// {name: "Sanghyeon Lee", gender: "male", city: "Seoul"}
Converting Object to Query String
const params = { name: "Sanghyeon Lee", gender: "male", city: "Seoul" };
const queryString =
Object.entries(params)
.map(entry => entry.map(encodeURIComponent))
.map((entry) => entry.join('='))
.join('&');
Using the pipe
function and currying, it can be written as follows:
const queryString = pipe(
Object.entries(params),
map(map(encodeURIComponent)),
map(join('=')),
join('&'),
);
Iterator-forEach Pattern
This pattern creates an iterator and then consumes data through lazy evaluation to produce side effects (forEach).
This pattern is mainly used when performing specific tasks (logging, output, network requests, etc.) while consuming an iterator.
No data is generated as a result, and the task itself becomes the purpose, making it suitable for such cases.
fx(range(5))
.map(x => x * 2)
.forEach(x => console.log('x', x));
The code for controlling asynchronous repetition that we looked at earlier also follows this pattern.
await fx(range(Infinity))
.toAsync()
.forEach(() => Promise.all([
syncPayments(),
delay(10000)
]));
Isolating Side Effects with forEach
forEach
is a method with no return value, designed explicitly to perform actions that involve side effects.
This design approach of isolating side effects plays an important role in improving code maintainability.
Pure data transformations are handled by methods like map
, filter
, reduce
, while side effects such as DOM removal, log writing, API calls are handled within forEach
.
Sometimes you need to return execution results while causing side effects. In such cases, using a function name like mapEffect
can indicate that it behaves similarly to map
but includes side effects.
Conceptual Overview of List Processing Functions by Type
I'd like to categorize list processing functions as follows:
Lazy Intermediate Operations
These operations defer computation until the result is actually needed, and they don't produce a final result on their own.
// Nothing is executed at this point yet
const pipeline = pipe(
[1, 2, 3, 4, 5],
map(x => {
console.log(`Processing: ${x}`); // Not output yet
return x * 2;
}),
filter(x => x > 4)
);
// Only executed when toArray is called
const result = toArray(pipeline); // Now "Processing: ..." is output
console.log(result); // [6, 8, 10]
Key Functions
map
,filter
: Data transformation and filteringzip
: Combining multiple iterablesflatten
: Flattening nested structureschunk
: Dividing data into chunks
Short-Circuit Intermediate Operations
These operations skip unnecessary computations when certain conditions are met.
// Real-world example: Finding the first valid configuration
const findValidConfig = (configs) =>
pipe(
configs,
map(config => {
console.log(`Validating: ${config.name}`);
return validateConfig(config); // Heavy validation logic
}),
takeWhile(config => !config.isValid), // Stop when a valid configuration is found
head // Return only the first result
);
// If the second configuration is valid, validation stops from the third one
Key Functions
take(n)
: Take only the first n itemstakeWhile(predicate)
: Take items as long as the condition is satisfiedtakeUntilInclusive(predicate)
: Take items including the one that satisfies the condition
Terminal Operations
These operations consume the actual iterable to produce a final result. Once called, the lazy evaluation is released and actual traversal occurs.
// Functions that finally consume iterables
const users = [
{ name: 'Alice', age: 25, isActive: true },
{ name: 'Bob', age: 30, isActive: false },
{ name: 'Charlie', age: 35, isActive: true }
];
// Each terminal operation - actual traversal occurs
const activeUser = pipe(users, find(user => user.isActive)); // Alice
const hasInactiveUser = pipe(users, some(user => !user.isActive)); // true
const allActive = pipe(users, every(user => user.isActive)); // false
const userArray = pipe(users, toArray); // Convert to array
Key Functions
find
: First element that matches the conditionsome
: Check if at least one element matches the conditionevery
: Check if all elements match the conditiontoArray
: Convert to arrayhead
: First element
Fold/Reduce Operations
Among terminal operations, these accumulate the entire sequence into a single value.
// Real-world example: Order data analysis
const orders = [
{ id: 1, amount: 15000, category: 'food', date: '2024-01-15' },
{ id: 2, amount: 8000, category: 'food', date: '2024-01-16' },
{ id: 3, amount: 25000, category: 'electronics', date: '2024-01-15' }
];
// reduce: Total sales sum
const totalSales = pipe(
orders,
map(order => order.amount),
reduce((sum, amount) => sum + amount, 0)
); // 48000
// groupBy: Grouping by category (accumulated into an object)
const ordersByCategory = pipe(
orders,
groupBy(order => order.category)
); // { food: [...], electronics: [...] }
// indexBy: Creating a map with ID as the key
const ordersById = pipe(
orders,
indexBy(order => order.id)
); // { 1: {...}, 2: {...}, 3: {...} }
Side Effects
Characteristics: Operations that change external state, such as output, logging, file writing, etc.
// Explicitly separating side effects
const processUserData = (users) =>
pipe(
users,
filter(user => user.isActive), // Pure function
map(user => enrichUserData(user)), // Pure function
tap(user => console.log(`Processing: ${user.name}`)), // Side effect (logging)
map(user => validateUser(user)), // Pure function
forEach(user => saveToDatabase(user)) // Side effect (DB saving)
);
Remembering these concepts will help you systematically understand 'when transformations occur', 'where data is actually consumed', and 'how final results are produced' when designing list processing pipelines, allowing you to write more efficient and readable code.