lfi: Lazy Functional Iteration Library

repository·main·Indexed 43 days ago

https://github.com/tomeraberbach/lfi

A lazy functional iteration library (version 4.1.2) supporting synchronous, asynchronous, and concurrent data processing. It provides highly composable, tree-shakeable tools for data transformation, including specialized ConcurIterables for concurrent execution, as well as utilities for filtering, deduplication, and collecting values into standard JavaScript collections like Map, Set, and Array.

Tokens
33.6K
Snippets
119
Records
131
Agent score
51%

What's inside lfi

  1. Overview of lfi features

    main

    lfi is a lazy functional iteration library designed for high composability. It supports various iteration patterns including synchronous, asynchronous, and concurrent execution.

    Key capabilities include:

    • Lazy Evaluation: Operations are delayed until their results are explicitly required.
    • Functional Programming: Provides highly composable functions for data transformation.
    • Multiple Iteration Types: Supports sync iterables, async iterables, and specialized concurrent iterables.
    • Async & Concurrent Execution: Allows applying async operations either sequentially over async iterables or concurrently over concurrent iterables.
    • Tree Shakeable: Optimized for modern bundlers to ensure only used code is included in your final bundle.
  2. What is an Optional in lfi?

    main

    In lfi, an Optional is a container of zero or one value. Unlike standard TypeScript nullable type unions, lfi represents optionals as iterables that produce either zero or one value.

    Depending on the execution context, optionals are represented by three types which are aliases for standard iterable types:

    • Optional: An alias for Iterable (synchronous).
    • AsyncOptional: An alias for AsyncIterable (asynchronous).
    • ConcurOptional: An alias for ConcurIterable (concurrent).

    Because they are just iterables, optionals can be lazy and are compatible with any function that expects a regular iterable. Note that lfi functions treat any iterable containing two or more values as an empty optional.

  3. What is a Reducer in lfi?

    main

    A reducer is a mechanism used to combine a lazy collection of values into a single final value, either synchronously or asynchronously. During the combination process, the intermediate state is referred to as the "accumulator".

    In lfi, functions that begin with the prefix to (such as toArray or toSum) are implemented as reducers.

  4. What is a Concurrent Iterable (Concur Iterable)?

    main

    A concurrent iterable (or "concur iterable") is a lazy collection of values that can be iterated concurrently. It is implemented as an object of type ConcurIterable containing a function that:

    • Does nothing until called.
    • Takes a callback that processes each emitted value.
    • Returns a Promise that resolves when every value has been handled or rejects if an error occurs.

    Conceptually, a concur iterable is a cold, push-based observable backed by asynchronous operations. Unlike AsyncIterable, which is sequential and pull-based, concur iterables push values out as fast as possible, meaning the iteration order is non-deterministic and depends on how quickly each value progresses through operations.

  5. How currying is used in `lfi`

    main

    In lfi, functions are automatically curried where applicable. This means that if a function (like filter or map) accepts multiple parameters, calling it with only its first few parameters will return a new function that expects the remaining parameters (typically the iterable).

    This pattern is designed to work seamlessly with the pipe function. When you use curried functions inside pipe, you can provide the transformation logic (like a predicate or a mapper) first, and the resulting function will wait for the data to be passed in by pipe.

    Benefits of this approach:

    1. Readability: The code follows a top-to-bottom reading order that matches the logical flow of data transformations.
    2. Tree Shaking: Unlike method chaining on prototypes, using curried functions with pipe allows bundlers to effectively remove unused functions from your final bundle.
    import { filter, map, pipe } from 'lfi'
    
    // Instead of filter(predicate, iterable)
    // You use the curried version: filter(predicate)
    // which returns a function: (iterable) => filteredIterable
    
    const processData = (data) => pipe(
      data,
      filter(item => item.active),
      map(item => item.value)
    )
  6. Use Full Reducers (Sync and Async)

    main

    A full Reducer (or AsyncReducer) is an object that allows the accumulator's type to differ from the type of the values in the iterable. This is useful for maintaining complex state (like a sum and a count simultaneously).

    Sync Reducer

    Pass an object to reduce() with:

    • create: A function to produce the initial accumulator value.
    • add: A function to combine the current accumulator with the next value.
    • finish (optional): A function to transform the final accumulator. Note: reduce() with a full reducer does not return an optional because create handles the zero-value case.

    Async Reducer

    Pass an object to reduceAsync() with:

    • create: An async function to produce the initial accumulator.
    • add: An async function to combine the accumulator and value.
    • combine (optional): An async function to combine two accumulators (useful for concurrent reductions).
    • finish (optional): An async function to transform the final accumulator.
    // Sync Full Reducer (e.g., calculating mean)
    import { map, pipe, reduce } from 'lfi'
    
    const meanSquares = numbers =>
      pipe(
        numbers,
        map(number => number * number),
        reduce({
          create: () => ({ sum: 0, count: 0 }),
          add: ({ sum, count }, number) => ({ sum: sum + number, count: count + 1 }),
          finish: ({ sum, count }) => (count === 0 ? NaN : sum / count),
        }),
      )
    
    // Async Full Reducer
    import { asAsync, mapAsync, pipe, reduceAsync } from 'lfi'
    
    const meanSquares = async numbers => {
      return await pipe(
        asAsync(numbers),
        mapAsync(number => number * number),
        reduceAsync({
          create: async () => ({ sum: 0, count: 0 }),
          add: async ({ sum, count }, number) => ({ sum: sum + number, count: count + 1 }),
          combine: async (acc1, acc2) => ({
            sum: acc1.sum + acc2.sum,
            count: acc1.count + acc2.count
          }),
          finish: async ({ sum, count }) => (count === 0 ? NaN : sum / count),
        }),
      )
    }
  7. Identify the correct Reducer type

    main

    Reducers in lfi are categorized by their execution mode (sync vs async), whether they might not produce a value (optional), and whether they require keyed access to the accumulator.

    While types like FunctionReducer and AsyncFunctionReducer exist, they are all normalized to one of the following core types via the normalizeReducer function:

    • Reducer / AsyncReducer: Standard reducers that produce a final value.
    • OptionalReducer / AsyncOptionalReducer: Reducers that may or may not produce a final value.
    • KeyedReducer / AsyncKeyedReducer: Reducers that require access to accumulator values by key.
  8. Use Function Reducers (Sync and Async)

    main

    A FunctionReducer is the simplest type of reducer. It defines how to combine two values of the same type into a single value of that same type.

    • Sync: Pass a function to reduce(). Because it doesn't define a behavior for empty iterables, reduce() returns an Optional. Use or() to provide a default value.
    • Async: Pass an async function to reduceAsync(). This works with async iterables (via asAsync) or concurrent iterables. reduceAsync() returns an AsyncOptional, so use orAsync() for defaults.
    // Sync Function Reducer
    import { map, or, pipe, reduce } from 'lfi'
    
    const sumSquares = numbers =>
      pipe(
        numbers,
        map(number => number * number),
        reduce((number1, number2) => number1 + number2),
        or(() => 0),
      )
    
    // Async Function Reducer
    import { asAsync, mapAsync, orAsync, pipe, reduceAsync } from 'lfi'
    
    const sumSquares = async numbers => {
      return await pipe(
        asAsync(numbers),
        mapAsync(number => number * number),
        reduceAsync(async (number1, number2) => number1 + number2),
        orAsync(() => 0),
      )
    }
  9. Compare Concur Iterables vs Chaining p-map/p-filter

    main

    Concur iterables offer two primary advantages over chaining standard promise utilities like p-map or p-filter:

    1. Laziness (No Intermediate Arrays): Concur iterables do not create intermediate arrays for each operation. Chaining p-map and p-filter creates $N-1$ intermediate arrays for $N$ operations, whereas lfi pipes process items through the pipeline without extra allocations.
    2. Independent Flow (No Bottlenecks): In a chain of p-map calls, the second operation cannot start until the first operation has completed for all items. In a ConcurIterable pipeline, each item flows through the operations independently. If one item is delayed by a specific operation, other items can continue through the rest of the pipeline.

    Example: Independent Flow

    import { asConcur, filterConcur, mapConcur, pipe, reduceConcur, toArray } from 'lfi'
    
    // Each item flows through operations independently.
    // Even if one item is slow in mapConcur, others can finish filterConcur.
    const withLfi = await pipe(
      asConcur([0, 1, 2]),
      mapConcur(mapFn),
      filterConcur(filterFn),
      reduceConcur(toArray()),
    )
  10. Compare Concur Iterables vs AsyncIterables

    main

    AsyncIterable (Sequential/Pull-based)

    • Behavior: Sequential. The next value is only pulled when the caller requests it (e.g., via for await...of).
    • Use Case: When the next value depends on the previous one, or when you need to manage backpressure.
    • Performance: Slower for independent async tasks because tasks are awaited one after another.

    Concur Iterable (Concurrent/Push-based)

    • Behavior: Concurrent. Values are pushed through the pipeline as fast as possible.
    • Use Case: When tasks are independent and you want maximum throughput.
    • Performance: Faster for independent async tasks as they run in parallel.

    Example Comparison

    import { asConcur, forEachConcur, mapConcur, pipe, rangeTo } from 'lfi'
    
    const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
    
    // Concur Iterable: Takes ~100ms (concurrent)
    const createConcurIterable = () =>
      pipe(
        rangeTo(0, 3),
        asConcur,
        mapConcur(() => delay(100)),
      )
    
    await forEachConcur(unused => console.log(`Iterated!`), createConcurIterable())
    
    // Async Iterable: Takes ~300ms (sequential)
    async function* createAsyncIterable() {
      yield delay(100)
      yield delay(100)
      yield delay(100)
    }
    
    for await (const unused of createAsyncIterable()) {
      console.log(`Iterated!`)
    }
  11. Use Keyed Reducers (Sync and Async)

    main

    A KeyedReducer is a specialized reducer designed for iterables containing key-value pairs (e.g., arrays of two values). It is primarily used by the toGrouped higher-order reducer.

    It requires a get function:

    • get: Produces the value in the accumulator associated with a given key, or the NO_ENTRY sentinel if the key is missing.

    Implementing a KeyedReducer is typically only necessary when building custom keyed collection types.

  12. Use Optional Reducers (Sync and Async)

    main

    An OptionalReducer is an object that allows you to transform the final result of a reduction using a finish function.

    • Structure: An object containing an add function (matching a FunctionReducer) and an optional finish function.
    • Sync: Pass to reduce(). Use or() to handle empty iterables.
    • Async: Pass to reduceAsync(). Use orAsync() to handle empty iterables.
    // Sync Optional Reducer
    import { map, or, pipe, reduce } from 'lfi'
    
    const sumSquaresMessage = numbers =>
      pipe(
        numbers,
        map(number => number * number),
        reduce({
          add: (number1, number2) => number1 + number2,
          finish: sum => `The sum of the squares is ${sum}`,
        }),
        or(() => `There were no numbers!`),
      )
    
    // Async Optional Reducer
    import { asAsync, mapAsync, orAsync, pipe, reduceAsync } from 'lfi'
    
    const sumSquaresMessage = async numbers => {
      return await pipe(
        asAsync(numbers),
        mapAsync(number => number * number),
        reduceAsync({
          add: async (number1, number2) => number1 + number2,
          finish: async sum => `The sum of the squares is ${sum}`,
        }),
        orAsync(() => `There were no numbers!`),
      )
    }