Practical Functional Programming

profile image

Applying functional programming in real-world scenarios with FxTS after reading Multi-paradigm Programming. Explore examples of solving complex data processing and asynchronous tasks declaratively.

This post has been translated by DeepL . Please let us know if there are any mistranslations!

Multi-paradigm Programming

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).

typescript
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.

type-inference.png

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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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.

typescript
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:

typescript
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:

  1. 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.
  2. Using forEach for repeated execution
    • It executes the task as it iterates through the iterable (in this case, infinitely).
  3. 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.

typescript
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.

typescript
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

typescript
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

typescript
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:

typescript
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.

typescript
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.

typescript
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.

typescript
// 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 filtering
  • zip: Combining multiple iterables
  • flatten: Flattening nested structures
  • chunk: Dividing data into chunks

Short-Circuit Intermediate Operations

These operations skip unnecessary computations when certain conditions are met.

typescript
// 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 items
  • takeWhile(predicate): Take items as long as the condition is satisfied
  • takeUntilInclusive(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.

typescript
// 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 condition
  • some: Check if at least one element matches the condition
  • every: Check if all elements match the condition
  • toArray: Convert to array
  • head: First element

Fold/Reduce Operations

Among terminal operations, these accumulate the entire sequence into a single value.

typescript
// 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.

typescript
// 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.

❤️ 0
🔥 0
😎 0
⭐️ 0
🆒 0