A tiny pipe function
One of the most elegant patterns in functional programming is composing small, focused functions into a readable pipeline. A tiny pipe function lets you chain transformations from left to right, making your data flow obvious and testable. In this article, I'll show you how to build and use a practical pipe implementation, and why you'll want to reach for it even in everyday TypeScript projects.
Why composition matters
When I first started building e-commerce platforms, I'd write data transformation logic in long imperative chains. A product name would get trimmed, lowercased, then slugified—each step scattered across different lines or nested function calls. The problem: readability suffers, and testing individual steps feels tedious.
Function composition solves this. Instead of writing:
const raw = " Hello World ";
const step1 = raw.trim();
const step2 = step1.toLowerCase();
const step3 = step2.replace(/\s+/g, "-");
console.log(step3); // "hello-world"With a pipe function, you compose your transformations declaratively:
const pipe = <T>(...fns: ((x: T) => T)[]) =>
(x: T) => fns.reduce((v, f) => f(v), x);
const clean = pipe(trim, lowercase, slugify);
console.log(clean(" Hello World ")); // "hello-world"The intent is immediate: take a value, feed it through trim, then lowercase, then slugify. No intermediate variables, no mental overhead.
Each function feeds the next
The magic of pipe is its simplicity. It takes a variadic list of functions and returns a new function that chains them using reduce. The output of one function becomes the input to the next.
Here's how it works step by step:
- Start with an initial value
x. - For each function
finfns, apply it to the accumulator value. - Return the final result after all functions have run.
In practice:
const trim = (s: string) => s.trim();
const lowercase = (s: string) => s.toLowerCase();
const slugify = (s: string) => s.replace(/\s+/g, "-");
const clean = pipe(trim, lowercase, slugify);
// Internally:
// 1. trim(" Hello World ") → "Hello World"
// 2. lowercase("Hello World") → "hello world"
// 3. slugify("hello world") → "hello-world"The beauty is that each function is completely independent. It doesn't know about the pipeline; it just transforms its input and passes the output forward. This decoupling is what makes pipe so powerful.
Readable data pipelines
Working on a CRM system, I needed to process contact data before saving it. Names had extra whitespace, inconsistent casing, and sometimes special characters. Using pipe made the intent crystal clear:
interface Contact {
name: string;
email: string;
}
const normalize = (s: string) => s.trim().toLowerCase();
const removeSpecialChars = (s: string) => s.replace(/[^a-z0-9\s-]/g, "");
const removeExtraSpaces = (s: string) => s.replace(/\s+/g, " ");
const sanitizeName = pipe(
normalize,
removeSpecialChars,
removeExtraSpaces
);
const processContact = (contact: Contact): Contact => ({
...contact,
name: sanitizeName(contact.name),
email: contact.email.toLowerCase().trim()
});
console.log(processContact({
name: " John O'Connor ",
email: " JOHN@EXAMPLE.COM "
}));
// {
// name: "john oconnor",
// email: "john@example.com"
// }When someone reads sanitizeName, they immediately understand the sequence: normalize, remove special chars, remove extra spaces. Compare that to nested function calls or imperative loops—pipe wins on clarity every time.
As your transformations grow, pipe keeps your code maintainable. You can add or remove steps without rewriting the calling code. You can even name intermediate pipes for different use cases:
const sanitizeNameForSearch = pipe(normalize, removeSpecialChars);
const sanitizeNameForDisplay = pipe(normalize, removeExtraSpaces);Easy to test each step
One of the biggest wins I've seen with pipe-based code is testability. Because each function is pure and independent, you can test transformations in isolation.
Instead of testing a monolithic process, you test the building blocks:
describe("Name sanitization", () => {
test("trim removes whitespace", () => {
expect(trim(" hello ")).toBe("hello");
});
test("lowercase converts to lowercase", () => {
expect(lowercase("HELLO")).toBe("hello");
});
test("removeSpecialChars strips non-alphanumeric", () => {
expect(removeSpecialChars("hello!@#")).toBe("hello");
});
test("sanitizeName combines all steps", () => {
expect(sanitizeName(" HELLO! ")).toBe("hello");
});
});Each function has a single, obvious responsibility. You test it once, and you're confident it works. When you compose them into a pipe, you're mostly just verifying the order is correct—the heavy lifting is already covered.
In complex data pipelines like the trading systems I've worked on, this approach saved hours of debugging. Bad data? Test each transformation function independently, and you'll spot the issue instantly.
Native pipeline operator is still a proposal
JavaScript has proposed a pipeline operator (|>) for years, but it remains a Stage 2 proposal and isn't available in production runtimes yet. This operator would let you write:
// Proposed syntax (not available yet)
" Hello World "
|> trim
|> lowercase
|> slugify;Until that lands, the pipe function pattern is your best alternative. It's been battle-tested in libraries like Ramda and Lodash, and it's simple enough that you can implement it yourself in seconds.
Some teams prefer using method chaining on custom wrapper objects, but pipe is language-agnostic and works with any functions. It's also lighter weight and easier to reason about.
Extending the pattern
Once you understand the basic pipe, you can extend it for more advanced scenarios.
Pipe with error handling:
const safePipe = <T>(...fns: Array<(x: T) => T | null>) =>
(x: T): T | null => {
for (const f of fns) {
x = f(x) ?? null;
if (x === null) return null;
}
return x;
};
const cleanEmail = safePipe(trim, lowercase, validateEmail);Pipe with different input/output types:
type Fn<A, B> = (x: A) => B;
const compose = <A, B, C>(f: Fn<A, B>, g: Fn<B, C>): Fn<A, C> =>
(x: A) => g(f(x));
// Compose with different types
const parseJson = (s: string) => JSON.parse(s);
const getUser = (obj: any) => obj.user;
const extractUser = compose(parseJson, getUser);Common mistakes
Assuming order doesn't matter: It does. Pipe is left-to-right. If you put slugify before lowercase, you'll get different results.
Mixing side effects into transformation functions: Keep your pipe functions pure. Don't log, don't fetch, don't mutate state. Save that for orchestration code around the pipe.
Creating overly generic pipes: Don't try to pipe everything. Use pipe when you have a clear sequence of transformations. Simple one-off transformations don't need it.
Forgetting type consistency: The basic pipe implementation requires all functions to have the same input and output type. If your transformations change types, use compose or higher-order variants instead.
Not naming intermediate pipes: A generic process or transform function is less useful than sanitizeName or normalizeEmail. Name your pipes for their intent.
Key takeaways
- A pipe function chains transformations left to right, making data flow explicit and readable.
- Each transformation function is independent, testable, and reusable across different pipelines.
- Pipe reduces cognitive load compared to nested function calls or imperative sequences.
- Use
reduceto thread values through a list of functions—it's simple, efficient, and elegant. - The native pipeline operator is still a proposal; pipe functions are your practical alternative today.
- Keep pipe functions pure: no side effects, consistent types, and clear naming for intent.
- Extend the pattern with error handling or type-changing composition for more complex scenarios.






Comments
Loading comments…