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 three functions are different ways of sequencing transformations.

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

import { flow, pipe } from "@nlozgachev/pipelined/composition";

// 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"]

compose is the right-to-left counterpart of flow. While flow(f, g) executes f first and g second, compose(g, f) runs them in reverse order, matching traditional mathematical notation ($g \circ f$):

import { compose, flow } from "@nlozgachev/pipelined/composition";

const double = (n: number) => n * 2;
const addOne = (n: number) => n + 1;

// flow: addOne(5) = 6, then double(6) = 12
flow(addOne, double)(5); // 12

// compose: double(5) = 10, then addOne(10) = 11
compose(addOne, double)(5); // 11

Use compose primarily when adapting code from third-party libraries that rely on right-to-left composition patterns.


Piping raw values through standard functions works well for clean, linear logic. However, real-world applications frequently require optional branches, safe error handling, object reshaping, or asynchronous steps. Rather than forcing you to write noisy inline arrow functions, the pipe namespace provides several specialized extensions.

Real-world flows often involve optional steps, such as applying a discount only if a user is a VIP. In standard code, this often results in verbose inline conditions:

const invoice = pipe(
  cart,
  (c) => c.isVip ? applyDiscount(c) : c,
  calculateTax,
);

pipe.when and pipe.unless allow you to describe these conditions declaratively, executing the step only when the predicate evaluates to true (or false):

import { pipe } from "@nlozgachev/pipelined/composition";

const invoice = pipe(
  cart,
  pipe.when((c) => c.isVip, applyDiscount),
  calculateTax,
);

pipe.either functions like an inline ternary branch, picking one of two functions to execute based on a condition:

const label = pipe(
  score,
  pipe.either((n) => n >= 80, () => "Excellent", () => "Needs Improvement"),
);

Certain functions (like JSON.parse or filesystem reads) throw runtime exceptions. Wrapping a single step in a try/catch block breaks the visual flow of a pipeline.

pipe.try intercepts exceptions for a specific step, allowing you to handle the error and supply a safe fallback value:

const config = pipe(
  rawJson,
  pipe.try(
    (s) => JSON.parse(s),
    (error) => {
      console.warn("Invalid config format; using defaults", error);
      return DEFAULT_CONFIG;
    },
  ),
  (c) => c.theme,
);

When deriving a structured object from a single input value, we often write verbose mapping wrappers.

pipe.struct allows you to construct a fresh object by running a record of field-level transformer functions on the piped value:

const summary = pipe(
  userProfile,
  pipe.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 }

Optional chaining (?.) is highly convenient, but when piping functions, you often have to guard each step to avoid runtime failures on empty values: (x) => x ? f(x) : null.

pipe.safe automatically short-circuits and propagates null or undefined the moment any intermediate step evaluates to a nil value:

const usernameLength = pipe.safe(
  apiResponse.user, // User | null
  (u) => u.profile,
  (p) => p.username,
  (name) => name.trim(),
  (name) => name.length,
); // Returns number | null (short-circuits if user or profile is null)

Standard pipelines expect synchronous steps. If a step returns a Promise, all subsequent steps receive the promise object itself instead of the resolved value.

pipe.async resolves promises returned at any stage before passing the resolved value to the next step, returning a final Promise:

const userConfig = await pipe.async(
  userId,
  fetchUserAsync,  // returns Promise<User>
  (user) => user.configId,
  fetchConfigAsync, // returns Promise<Config>
); // Promise<Config>

Symmetrically to pipe, the flow namespace provides extensions that compile these branching, catching, and asynchronous behaviors into reusable, deferred functions.

flow.when and flow.unless let you inject optional steps into a reusable pipeline:

import { flow } from "@nlozgachev/pipelined/composition";

const processItem = flow(
  calculateTax,
  flow.when((item) => item.isOnSale, applyDiscount),
  formatPrice,
);

flow.either generates a branching step that picks one of two paths:

const classifyScore = flow(
  flow.either((score) => score >= 80, () => "Excellent", () => "Pass"),
);

flow.try creates a reusable step that wraps a throwing operation in a safety net, converting exceptions into default or fallback values:

const parseConfig = flow(
  flow.try(
    (s: string) => JSON.parse(s),
    (error) => {
      console.error("Config parse failure:", error);
      return DEFAULT_CONFIG;
    },
  ),
  (config) => config.theme,
);

flow.struct compiles a record of transformer functions into a single mapping function that produces a structured object:

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

flow.safe creates a reusable pipeline that safely propagates null or undefined down the chain, short-circuiting on nil values:

const getUsernameLength = flow.safe(
  (u: User | null) => u?.profile,
  (p) => p.username,
  (name) => name.length,
); // Returns a function: (u: User | null) => number | null

flow.async creates an asynchronous composition chain, awaiting intermediate promises before moving to the next transformation:

const loadConfig = flow.async(
  fetchUserAsync, // returns Promise<User>
  (user) => user.configId,
  fetchConfigAsync, // returns Promise<Config>
); // Returns a function: (userId: string) => Promise<Config>

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 "@nlozgachev/pipelined/composition";

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. curry3 and curry4 handle three- and four-argument functions respectively:

import { curry3 } from "@nlozgachev/pipelined/composition";

const between = curry3(
  (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. This is useful when passing curried library helpers to external APIs that expect standard JavaScript functions:

import { curry, uncurry } from "@nlozgachev/pipelined/composition";

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

add(3, 4); // 7

uncurry3 and uncurry4 provide the same reversal for three- and four-argument signatures.


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 "@nlozgachev/pipelined/composition";

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 "@nlozgachev/pipelined/composition";

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

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, pipe } from "@nlozgachev/pipelined/composition";
import { Maybe } from "@nlozgachev/pipelined/core";

pipe(Maybe.some(42), Maybe.fold(() => 0, identity)); // 42

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

import { constant } from "@nlozgachev/pipelined/composition";

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

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

constTrue, constFalse, constNull, constUndefined, and constVoid are optimized shortcuts for common static returns.

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

import { once } from "@nlozgachev/pipelined/composition";

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)

not inverts a predicate function:

import { not } from "@nlozgachev/pipelined/composition";

const isEven = (n: number) => n % 2 === 0;
const isOdd = not(isEven);

[1, 2, 3, 4, 5].filter(isOdd); // [1, 3, 5]

and and or combine two predicate functions with standard short-circuit evaluation:

import { and, or } from "@nlozgachev/pipelined/composition";

const isPositive = (n: number) => n > 0;
const isInteger = (n: number) => Number.isInteger(n);

const isPositiveInteger = and(isPositive, isInteger);
isPositiveInteger(3.5); // false

const isNonZero = or(isPositive, (n: number) => n < 0);
isNonZero(0); // false

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

import { memoize } from "@nlozgachev/pipelined/composition";

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:

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

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 "@nlozgachev/pipelined/composition";

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 "@nlozgachev/pipelined/composition";

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 "@nlozgachev/pipelined/composition";

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 "@nlozgachev/pipelined/composition";

interface Track { duration: number }

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

  • pipe — Sequence transformations top-to-bottom on an immediate value.
  • pipe.when / pipe.unless — Run a step conditionally in a pipeline.
  • pipe.either — Branch a pipeline step to one of two paths based on a condition.
  • pipe.try — Wrap a single throwing step with an error fallback.
  • pipe.struct — Reshape a single input value into a structured object of transforms.
  • pipe.safe — Short-circuit and propagate null/undefined across steps automatically.
  • pipe.async — Sequence transformations containing asynchronous transitions.
  • flow — Build a reusable, deferred transformation function.
  • compose — Sequence reusable transformations right-to-left.
  • curry / curry3 / curry4 — Convert multi-argument functions to curried steps.
  • uncurry / uncurry3 / uncurry4 — Convert curried functions back to multi-argument format.
  • flip — Reverse the argument order of a curried binary function.
  • identity — A no-op callback that returns its input exactly as received.
  • constant — Create a function that always returns a static value.
  • once — Ensure a function executes only on its first invocation.
  • not / and / or — Combine and invert predicate functions.
  • memoize — Cache results using primitive keys.
  • memoizeWeak — Cache results using object keys, allowing garbage collection.
  • tap — Execute a side effect in a pipeline without altering the value.
  • converge — Fan out an input through multiple transformers and combine the results.
  • juxt — Fan out an input through multiple transformers and collect them in a tuple.
  • on — Build comparators or equality checks by comparing projected properties.