Skip to content

Thinking in pipelines

Most transformations in software follow a simple, linear path: we take a starting value, apply a sequence of steps to alter it, and obtain a result. The architectural challenge is how to express this flow in a way that is readable, easy to modify, and safe to extend.

In standard JavaScript, we tend to write transformations in one of two ways. The first is to introduce intermediate variables:

const trimmed = raw.trim();
const lower = trimmed.toLowerCase();
const slug = lower.replace(/\s+/g, "-");

This reads top-to-bottom, but every intermediate variable is noise. Names like trimmed and lower exist only to carry a value to the next line. If you need to reorder, add, or remove a step, you are forced to rename variables to keep the code consistent.

The second approach is to nest the method calls directly:

const slug = raw.trim().toLowerCase().replace(/\s+/g, "-");

This eliminates intermediate variables, but it only works when the object you are transforming already has the methods you need. The moment you want to introduce a custom function or use a utility from an external library, the chain breaks. You are forced to break the line apart or write confusing, nested function calls:

const slug = formatSlug(raw.trim().toLowerCase());

This reads inside-out. The execution starts in the middle, moves outward, and forces the developer to jump back and forth to understand the execution order.


pipe solves this by taking a starting value and passing it through a sequence of functions. Each function receives the output of the previous step:

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

const slug = pipe(
  raw,
  (s) => s.trim(),
  (s) => s.toLowerCase(),
  (s) => s.replace(/\s+/g, "-"),
);

The code now reads top-to-bottom, in exactly the order it executes. There are no throwaway variable names, and modifying the pipeline is as simple as inserting or deleting a line.

TypeScript infers the type of the value at each step. If one of your functions returns a type that the next step does not expect, the compiler will point to the exact step where the mismatch occurs, rather than showing a generic error downstream.


Any unary function—a function that accepts a single argument—can be passed as a step in a pipeline. This includes inline lambda functions, named functions, or partially-applied library helpers:

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

const displayName = pipe(
  users.get(userId),             // User | undefined
  Maybe.fromNullable,            // Maybe<User>
  Maybe.map((u) => u.name),      // Maybe<string>
  Maybe.getOrElse(() => "guest"), // string
);

Notice that Maybe.fromNullable is passed directly as a value, without wrapping it in an arrow function.

Furthermore, Maybe.map((u) => u.name) is invoked with only a mapping function. It returns a new function that is waiting for the Maybe value, which pipe supplies. This is made possible by the data-last convention.


pipe evaluates immediately, producing a result. flow operates the same way but defers execution—it returns a new, reusable function:

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

const toSlug = flow(
  (s: string) => s.trim(),
  (s) => s.toLowerCase(),
  (s) => s.replace(/\s+/g, "-"),
);

toSlug("  Hello World  "); // "hello-world"
toSlug("TypeScript Pipes"); // "typescript-pipes"

Because toSlug is a standard, single-argument function, we can pass it directly to other functional utilities (like Array.prototype.map) without wrapping it in an arrow function:

const rawTitles = ["  Clean Code ", " Functional Design "];
const slugs = rawTitles.map(toSlug); // ["clean-code", "functional-design"]

The rule of thumb is simple: use pipe when you have a value ready and want to compute a result immediately. Use flow when you want to define and name a reusable transformation pipeline.


Every function in this library is designed with a data-last signature. The value being operated on is always the final argument.

If the library used data-first signatures, we would be forced to write noisy arrow wrappers for every step in our pipeline:

// Data-first: forces manual wrapping
pipe(
  option,
  (opt) => Maybe.map(opt, (u) => u.name),
  (opt) => Maybe.getOrElse(opt, () => "guest"),
);

Because pipelined is built data-last, the configuration is provided first, and the resulting function is ready to receive the data:

// Data-last: clean and point-free
pipe(
  option,
  Maybe.map((u) => u.name),
  Maybe.getOrElse(() => "guest"),
);

This design allows library functions to slot directly into pipe and flow with zero visual overhead.


When you want to inspect a value mid-pipeline—such as logging it or updating an analytics tracker—without altering the data or breaking the chain, you can use tap:

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

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

tap intercepts the value, executes your side-effectful callback, and then passes the original value through unchanged to the next step. If you remove the tap lines, the behavioral outcome of the pipeline remains identical.


A pipeline is most readable when each step performs a single, focused task. If a step expands into multiple lines of complex logic, it is a signal that you should extract it into a named function:

// Hard to read inline block:
pipe(
  rawInput,
  (s) => {
    const trimmed = s.trim();
    const parts = trimmed.split(",");
    return parts.filter((p) => p.length > 0).map((p) => p.toLowerCase());
  },
);

// Clean extraction:
const parseTokens = (s: string): string[] =>
  s.trim().split(",").filter((p) => p.length > 0).map((p) => p.toLowerCase());

pipe(
  rawInput,
  parseTokens,
);

By extracting the implementation details into a named function, the pipeline itself remains a high-level description of what happens. The named function describes how. Both levels of code become independently readable and testable.