TL;DR (Quick Summary)#
- The Problem: Nesting function calls creates the unreadable “Pyramid of Doom”. Object-Oriented method chaining (
.map().filter()) is readable, but tightly couples data to behavior and requires modifying prototypes. - Function Composition: A mathematical concept where the output of Function A is passed directly as the input to Function B.
- The
pipeFunction: Executes a sequence of functions immediately, passing data from top to bottom. It reads like a recipe. - The
flowFunction: Composes a sequence of functions into a new, reusable function without executing it immediately. - Effect-TS Connection: The
pipeoperator is the absolute lifeblood of Effect-TS. You will use it in every single file you write.
1. Introduction: The Composition Problem#
If you follow the rules of Functional Programming, you will end up with dozens of small, highly testable, pure functions. But how do you combine them to achieve complex business logic?
Imagine we have three pure functions:
const addTax = (amount: number) => amount * 1.2;
const applyDiscount = (amount: number) => amount - 10;
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`;The Pyramid of Doom (Nested Calls)#
The standard imperative way to combine these functions is by nesting them.
// 🔴 Hard to read (Evaluated inside-out)
const result = formatCurrency(applyDiscount(addTax(100)));
console.log(result); // "$110.00"
To understand what happens to the number 100, your brain has to read the code from right-to-left (or inside-out). This is acceptable for three functions, but if you have a pipeline of ten operations, it becomes a horrific “Pyramid of Doom” that is impossible to decipher.
The Object-Oriented Approach (Method Chaining)#
Object-Oriented Programming (and standard JavaScript Arrays) solve this using Method Chaining.
// 🟡 Easier to read (Top-down)
const result = new Money(100)
.addTax()
.applyDiscount()
.formatCurrency();This reads beautifully from top to bottom. However, it requires that addTax, applyDiscount, and formatCurrency are methods permanently attached to the Money class prototype. If you want to add a new operation (e.g., convertToEuros), you have to physically modify the Money class. This violates the Open-Closed Principle and bloats the class.
2. The Functional Solution: The pipe Operator#
Functional Programming gives us the readability of Method Chaining without the tight coupling of Object-Oriented classes. We achieve this using a utility function called pipe.
The pipe function takes an initial value and passes it through a sequence of pure functions.
Step-by-Step: Using pipe#
Libraries like Effect-TS and fp-ts provide a highly optimized pipe function out of the box.
import { pipe } from "effect/Function";
// 🟢 Perfect Readability, Zero Coupling
const result = pipe(
100, // Initial Value
addTax, // Output: 120
applyDiscount, // Output: 110
formatCurrency // Output: "$110.00"
);
console.log(result); // "$110.00"
Notice how pipe completely transforms the developer experience:
- It reads perfectly from top to bottom.
- Any function that takes one argument and returns a value can be plugged into the pipeline.
- You don’t need to modify any classes to add new operations.
3. The flow Operator: Reusable Pipelines#
While pipe executes the pipeline immediately given an initial value, what if you want to save the pipeline as a reusable function to run later?
This is where flow comes in. flow (often called function composition) takes a sequence of functions and returns a brand new function.
import { flow } from "effect/Function";
// 🟢 Create a reusable pipeline (Does NOT execute yet)
const processOrder = flow(
addTax,
applyDiscount,
formatCurrency
);
// Execute it later
const order1 = processOrder(100); // "$110.00"
const order2 = processOrder(50); // "$50.00"
When to use pipe vs flow?#
- Use
pipewhen you have the data right now and you want the final result. - Use
flowwhen you are defining a callback, a utility function, or an event handler that will receive data later.
4. Method Chaining vs Pipe#
| Feature | OOP Method Chaining (obj.doX()) | FP Pipe (pipe(data, doX)) |
|---|---|---|
| Extensibility | Hard. Requires modifying the class prototype. | Trivial. Just pass any standalone function. |
| Tree Shaking | Poor. If you import the class, you bundle every method, even unused ones. | Excellent. You only bundle the specific pure functions you actually import. |
| Data Coupling | High. Data and behavior are locked together. | Zero. Data and behavior are completely isolated. |
5. Connecting Pipe with ADTs (Options & Eithers)#
In the previous episode, we learned about Option and Either. The true magic of functional programming happens when we combine pipe with mapping functions to safely transform data inside those boxes!
import { pipe } from "effect/Function";
import * as Option from "effect/Option";
const getOptionalUser = (): Option.Option<string> => Option.some("rachmat");
// We can pipe operations directly into the Option Monad!
const result = pipe(
getOptionalUser(),
Option.map(name => name.toUpperCase()),
Option.map(name => `Welcome, ${name}!`)
);
// If getOptionalUser returned None, the mapping functions are safely skipped!
console.log(result); // Option.some("Welcome, RACHMAT!")
This is the exact paradigm you will use every day in Effect-TS. You will start with an Effect, and pipe it through a series of transformations, safe in the knowledge that errors and missing data are handled automatically.
6. Troubleshooting & Common Errors#
Error 1: Type Inference Breaking in Pipe#
TS2345: Argument of type '(x: unknown) => unknown' is not assignable to parameter of type...The Cause: In TypeScript, the pipe function infers types from top to bottom. If one of your functions in the middle of the pipeline loses its type (e.g., returns any or unknown), every function below it will break.
The Fix: Ensure every single function in your pipeline has strict return types.
// 🔴 Bad: Implicit any return type
const badAddTax = (x: any) => x * 1.2;
// 🟢 Good: Strict typing ensures the pipeline flows safely
const goodAddTax = (x: number): number => x * 1.2;Error 2: Passing multi-argument functions to Pipe#
The Mistake: pipe expects every function in the chain (except the very first one in flow) to take exactly one argument.
const multiply = (a: number, b: number) => a * b;
// 🔴 Bad: multiply requires two arguments, but pipe only passes one (the previous result)
pipe(10, multiply);
The Fix: You must “curry” your functions or use closures to ensure the function passed to pipe only requires one argument.
// 🟢 Good: A function that returns a function (Currying)
const multiplyBy = (multiplier: number) => (value: number) => value * multiplier;
pipe(
10,
multiplyBy(2) // Evaluates to (value) => value * 2, which fits perfectly!
);Summary & Next Steps#
In this episode:
- We solved the “Pyramid of Doom” without relying on OOP Method Chaining.
- We used
pipeto execute a sequence of pure functions from top-to-bottom. - We used
flowto compose functions into reusable pipelines. - We demonstrated how
pipeenables massive Tree-Shaking benefits for frontend bundles.
You now possess the entire theoretical foundation of Functional Architecture: Pure Functions, Immutability, Algebraic Data Types, and Function Composition.
It is finally time to enter the ecosystem.
In Episode 61: Introduction to Effect-TS, we will install the library, understand its core philosophy, and write our first enterprise-grade Effect!

