Skip to content

Composition utilities

The Composition module is the engine room of this library. Beyond pipe and flow, it provides a collection of small, focused utilities to shape, branch, cache, and adapt functions before they enter a pipeline.

This guide acts as a comprehensive reference for these tools. If you are new to the concepts of piping and flow, start with Thinking in pipelines first.


These two functions sequence transformations top-to-bottom.

pipe evaluates a starting value immediately through a sequence of steps. flow defers execution, returning a reusable function.

import { flow, pipe } from "@pplnd/core";

// pipe: immediate evaluation
const slug = pipe(
  "  Hello World  ",
  (s) => s.trim(),
  (s) => s.toLowerCase(),
  (s) => s.replace(/\s+/g, "-"),
); // "hello-world"

// flow: deferred evaluation
const toSlug = flow(
  (s: string) => s.trim(),
  (s) => s.toLowerCase(),
  (s) => s.replace(/\s+/g, "-"),
);
const slugs = ["  Alice  ", "  Bob  "].map(toSlug); // ["alice", "bob"]

Piping raw values through standard functions works well for clean, linear logic. However, real-world applications frequently require optional branches. Rather than forcing you to write noisy inline arrow functions with ternary expressions, Composition provides declarative branching combinators that slot into pipe and flow.

when executes a transformation only when a predicate evaluates to true, whereas unless executes the transformation only when the predicate evaluates to false, returning the original input value unchanged otherwise:

import { pipe, unless, when } from "@pplnd/core";

const invoice = pipe(
  cart,
  when((c) => c.isVip, applyVipDiscount),
  unless((c) => c.isTaxExempt, addSalesTax),
);

When deriving a structured record from a single input value, mapping wrapper functions can become repetitive.

struct compiles a record of field-level transformer functions into a single transformation that maps the input into a structured object:

import { flow, pipe, struct } from "@pplnd/core";

const summary = pipe(
  userProfile,
  struct({
    fullName: (u) => `${u.firstName} ${u.lastName}`,
    age: (u) => calculateAge(u.birthDate),
    isAdmin: (u) => u.roles.includes("admin"),
  }),
); // { fullName: "Alice Smith", age: 30, isAdmin: true }

const buildSummary = flow(
  struct({
    name: (u: User) => `${u.firstName} ${u.lastName}`,
    age: (u: User) => calculateAge(u.birthDate),
  }),
);

Asynchronous pipelines: pipeAsync and flowAsync

Section titled “Asynchronous pipelines: pipeAsync and flowAsync”

pipe and flow are strictly synchronous and infallible. If a step returns a Promise, standard pipe does not await it — subsequent steps receive the promise instance itself rather than the resolved value.

When a pipeline includes asynchronous transitions, use the dedicated async runners:

  • pipeAsync: Evaluates a starting value or promise immediately through a sequence of synchronous or asynchronous steps, returning a Promise.
  • flowAsync: Compiles a sequence of steps into a reusable function that returns a Promise.
import { flowAsync, pipeAsync } from "@pplnd/core";

// pipeAsync: immediate execution
const userConfig = await pipeAsync(
  userId,
  fetchUserAsync,   // returns Promise<User>
  (user) => user.configId,
  fetchConfigAsync, // returns Promise<Config>
);

// flowAsync: reusable deferred pipeline
const loadConfig = flowAsync(
  fetchUserAsync,
  (user) => user.configId,
  fetchConfigAsync,
);

A curried function accepts its arguments one at a time. curry converts a standard two-argument function into a sequence of single-argument functions:

import { curry } from "@pplnd/core";

const clamp = curry((min: number, n: number) => Math.max(min, n));
const atLeastZero = clamp(0);

atLeastZero(-5); // 0
atLeastZero(3);  // 3

This is highly useful for preparing multi-argument functions to slot as steps inside pipe or flow. curry is overloaded to support functions of 2, 3, or 4 arguments:

import { curry } from "@pplnd/core";

const between = curry(
  (min: number, max: number, n: number) => Math.min(max, Math.max(min, n)),
);
const clamp0to100 = between(0)(100);

clamp0to100(150); // 100
clamp0to100(-10); // 0

uncurry is the inverse of curry. It converts a curried, single-argument function back into a standard multi-argument format (supporting 2, 3, or 4 arguments). This is useful when passing curried library helpers to external APIs that expect standard JavaScript functions:

import { curry, uncurry } from "@pplnd/core";

const curriedAdd = curry((a: number, b: number) => a + b);
const add = uncurry(curriedAdd);

add(3, 4); // 7

flip reverses the argument order of a curried binary function. Its main purpose is to adapt data-last library functions to a data-first format for use outside of pipelines:

import { flip } from "@pplnd/core";

const prepend = (prefix: string) => (str: string) => prefix + str;
const append = flip(prepend);

prepend("Hello, ")("World"); // "Hello, World"
append("World")("Hello, ");  // "Hello, World"

tap executes a function for its side effect (such as logging or performance auditing) and returns the original value unchanged, leaving the pipeline’s behavior undisturbed:

import { pipe, tap } from "@pplnd/core";

const result = pipe(
  rawInput,
  parse,
  tap((v) => console.log("Parsed result:", v)),
  validate,
  format,
);

For debugging, performance auditing, and background tasks, the module provides several standalone, configuration-based side-effect helpers: tapLog, tapInspect, tapAsync, and tapTime.

tapLog logs the piped value to the console. You can optionally supply a label to prefix the log output, a custom logger destination, or a custom string formatter (which defaults to JSON.stringify for objects):

import { pipe, tapLog } from "@pplnd/core";

pipe(
  42,
  tapLog(), // logs: 42
  tapLog({ label: "Total Score" }), // logs: [Total Score]: 42
  tapLog({
    label: "Custom Format",
    formatter: (n) => `Value is ${n * 2}`,
  }), // logs: [Custom Format]: Value is 84
);

Detailed object inspection with tapInspect

Section titled “Detailed object inspection with tapInspect”

For deeply nested objects, tapLog might output truncated representations. tapInspect formats objects with infinite depth by default using Node’s util.inspect engine:

import { pipe, tapInspect } from "@pplnd/core";

pipe(
  { user: { name: "Alice", details: { age: 30, roles: ["admin"] } } },
  tapInspect({ label: "User Payload", depth: 2 })
);
// logs: [User Payload]: { name: 'Alice', details: { age: 30, roles: [Array] } }

To trigger asynchronous operations (like telemetry hits, audits, or database writes) without blocking the pipeline or turning it into an async promise chain, use tapAsync. It runs the promise in the background, intercepts any rejections, and routes them to onError (defaulting to console.error):

import { pipe, tapAsync } from "@pplnd/core";

pipe(
  userId,
  tapAsync(
    async (id) => {
      await saveAnalyticsSession(id);
    },
    { onError: (err) => logError(err) }
  )
); // returns userId immediately; analytics save runs in background

tapTime measures the execution duration of a side-effect function. It supports both synchronous and asynchronous operations (for promises, the duration is tracked until the promise resolves or rejects).

The configuration object requires either a label (to log to the console automatically) or an onFinish callback (to route the typed Duration elsewhere), which are mutually exclusive to avoid conflicts:

import { Duration, pipe, tapTime } from "@pplnd/core";

// Scenario A: Auto-log to console
pipe(
  data,
  tapTime(syncCalculation, { label: "heavy-math" })
); // logs: [heavy-math]: 12ms

// Scenario B: Custom APM callback receiving Duration
pipe(
  data,
  tapTime(asyncApiFetch, {
    onFinish: (duration) => {
      metrics.send("api.duration_ms", Duration.to.milliseconds(duration));
    }
  })
);

identity returns its input argument exactly as received. It is commonly used as a fallback callback where a no-op transformation is required:

import { identity, Maybe, pipe } from "@pplnd/core";

pipe(Maybe.make.some(42), Maybe.match({ some: identity, none: () => 0 })); // 42

constant returns a function that always yields the same fixed value, ignoring whatever input is supplied to it:

import { constant } from "@pplnd/core";

const alwaysFive = constant(5);
alwaysFive(); // 5

[1, 2, 3].map(constant("placeholder")); // ["placeholder", "placeholder", "placeholder"]

once ensures a function is only executed on its first invocation, caching and returning that original result for all subsequent calls:

import { once } from "@pplnd/core";

const initConnection = once(() => {
  console.log("Initializing database connection...");
  return connect();
});

initConnection(); // Logs message and connects
initConnection(); // Returns the cached connection (no second log or connection)

tuple and untuple adapt functions between positional multi-argument signatures and single-tuple argument signatures. Use tuple when a pipeline step yields a [A, B] pair (such as Arr.zip or juxt) that you want to pass into a standard multi-argument function, and untuple when passing a tuple-accepting function to a callback API that supplies separate arguments:

import { pipe, tuple, untuple } from "@pplnd/core";

const formatRange = (min: number, max: number) => `${min}px - ${max}px`;
const formatRangeTuple = tuple(formatRange);

pipe([10, 50] as const, formatRangeTuple); // "10px - 50px"

const sumPair = ([a, b]: readonly [number, number]) => a + b;
const add = untuple(sumPair);

add(15, 25); // 40

To negate, combine, or project boolean checks, use the Predicate module. It provides Predicate.not, Predicate.and, Predicate.or, Predicate.using, Predicate.all, and Predicate.any. For full details, see the Predicate guide.


memoize wraps a function, caching the results of repeated calls using the input argument as the cache key:

import { memoize } from "@pplnd/core";

const fibonacci = memoize((n: number): number =>
  n <= 1 ? n : fibonacci(n - 1) + fibonacci(n - 2)
);

fibonacci(40); // Computed and cached
fibonacci(40); // Returned instantly from cache

For complex object keys, you can supply a custom key-generator function to evaluate equivalence structurally, or specify a maxSize parameter to constrain memory usage via least-recently-used (LRU) eviction:

const loadProfile = memoize(
  (opts: { userId: string }) => fetchProfile(opts.userId),
  { key: (opts) => opts.userId, maxSize: 500 },
);

memoizeWeak performs the same caching behavior but stores entries using a WeakMap, ensuring that cache entries are automatically garbage-collected when the key object is no longer referenced in memory:

import { memoizeWeak } from "@pplnd/core";

const cachedLayout = memoizeWeak((element: HTMLElement) => computeLayout(element));

converge takes a combining function and an array of transformers. It applies a single input value to each transformer independently, passing the collected results to the combiner:

import { converge } from "@pplnd/core";

interface Item { price: number }

const summarize = converge(
  (subtotal: number, count: number) => ({ subtotal, count }),
  [
    (items: Item[]) => items.reduce((sum, i) => sum + i.price, 0),
    (items: Item[]) => items.length,
  ],
);

summarize([{ price: 10 }, { price: 20 }]); // { subtotal: 30, count: 2 }

juxt is the simpler sibling of converge. It applies a single input to an array of transformer functions and returns all outcomes in a typed tuple:

import { juxt } from "@pplnd/core";

const parseName = juxt([
  (name: string) => name.split(" ")[0],
  (name: string) => name.split(" ").slice(1).join(" "),
]);

const [first, last] = parseName("Alice Smith"); // ["Alice", "Smith"]

on takes a binary comparator and a projection function. It projects both input arguments before comparing them, removing the need to repeat key lookups in sorting and equality functions:

import { on } from "@pplnd/core";

interface Track { duration: number }

const byDuration = on((a: number, b: number) => a - b, (t: Track) => t.duration);
const sorted = [...tracks].sort(byDuration);

  • Eliminating deeply nested calls and intermediate variables (pipe, flow, pipeAsync, flowAsync): Sequencing synchronous and asynchronous operations top-to-bottom in execution order without temporary local variables (const step1 = ...; const step2 = ...) or inside-out nested function calls.
  • Declarative inline branching (when, unless): Applying conditional transformations inside a pipeline (such as applying promotional discounts or skipping tax calculations for exempt accounts) without breaking the linear flow with manual ternary wrappers.
  • Deriving and combining parallel projections (struct, converge, juxt): Computing multiple independent values from a single input — whether assembling a summary object (struct), feeding parallel calculations into a combiner (converge), or collecting tuple results (juxt)—without referencing the input variable repeatedly.
  • Non-intrusive pipeline observability (tap, tapLog, tapInspect, tapAsync, tapTime): Logging values, inspecting deep payloads, firing background telemetry, and measuring step execution durations mid-pipeline without altering return types or disrupting data flow.
  • Adapting function signatures across boundaries (curry, uncurry, flip, tuple, untuple, on): Bridging multi-argument callbacks, tuple pairs, and third-party APIs with data-last pipeline steps without repetitive wrapper functions.
  • Bounding redundant computations (once, memoize, memoizeWeak): Guaranteeing one-time initialization for resources (once) and caching expensive pure calculations by primitive key (memoize with optional LRU maxSize) or object reference (memoizeWeak).