Effect's pipe: the backbone of composable TypeScript
21 Jun 2025You know that feeling when you chain Promises and suddenly youâre drowning in nested try-catch blocks? Effectâs pipe function takes a radically different approach. Itâs not just another functional programming utilityâitâs a complete rethinking of how we compose operations in TypeScript.
The pipe that knows what can go wrong
Hereâs what makes Effectâs pipe different right off the bat:
const result = pipe(
Effect.succeed(42),
Effect.map(n => n / 2),
Effect.flatMap(n => n > 20 ? Effect.succeed(n) : Effect.fail("too small")),
Effect.mapError(msg => new ValidationError(msg))
)
// Type: Effect<number, ValidationError, never>
See that type signature? Every possible error is tracked. Every dependency is explicit. This isnât your typical pipe function that just threads values through transformations. Effectâs pipe carries three pieces of information through every step: the success value, possible errors, and required dependencies.
How Effect pulls off this magic
The implementation relies on TypeScriptâs overloading system pushed to its limits. Effect provides 20+ overloads to maintain type safety through long pipelines:
pipe<A, B, C, D>(
a: A,
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D
): D
But hereâs where it gets interesting. Effect uses a âdual APIâ pattern. Every function works two ways:
import { dual, pipe } from "effect/Function"
const sum = dual<
(that: number) => (self: number) => number,
(self: number, that: number) => number
>(2, (self, that) => self + that)
// Both styles work
sum(2, 3) // Data-first: 5
pipe(2, sum(3)) // Data-last: 5
This isnât just syntactic sugar. Itâs about flexibility. Sometimes you want method chaining. Sometimes you want function composition. Effect says: why choose?
Real-world patterns
Hereâs a common pattern that works well:
const createInvitation = ({ email, role, userId, orgId }) =>
pipe(
validateInvitationData({ email, role }),
Effect.andThen(() => checkUserPermissions({ userId, orgId })),
Effect.andThen(() => generateInvitationId()),
Effect.flatMap(invitationId =>
saveInvitation({ invitationId, email, role, orgId })
),
Effect.tap(() => sendInvitationEmail(email)),
Effect.mapError(error =>
error instanceof DatabaseError
? new InternalServerError("Database operation failed")
: error
),
Effect.retry({ times: 3, schedule: Schedule.exponential(1000) }),
Effect.timeout(10000)
)
Notice how each concern is separate? Validation, authorization, business logic, error handling, retries, timeoutsâall composed through pipe. In Promise-land, this would be a tangled mess of try-catch blocks and manual retry logic.
Performance: the elephant in the room
Letâs address it head-on. Effect has overhead. The fiber-based runtime adds ~15KB to your bundle (compressed). Initial execution is slower than raw Promises.
But hereâs the thing: Effect shines when complexity grows. That weather app fetching data from three APIs? Effectâs concurrent execution model offers advantages over Promise.all:
const weatherData = pipe(
Effect.all({
current: fetchCurrentWeather(city),
forecast: fetchForecast(city),
alerts: fetchWeatherAlerts(city)
}, { concurrency: "unbounded" }),
Effect.retry({ times: 3, schedule: Schedule.exponential(500) }),
Effect.timeout(5000)
)
Each operation gets its own retry logic. One fails? Others continue. The fiber runtime handles this elegantly while Promises would require custom orchestration.
Comparing to other pipes
F#âs pipe operator |> provides readable left-to-right composition:
[1; 2; 3; 4; 5]
|> List.filter (fun x -> x % 2 = 0)
|> List.map (fun x -> x * x)
|> List.sum
Beautiful. Type-safe. But itâs just syntax. No error tracking, no async handling, no dependency injection.
Elixir also uses pipe operators for data transformation:
"hello world"
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
Elixirâs strength is combining pipes with pattern matching in function definitions, letting you handle different data shapes elegantly. But like F#, when you need explicit error tracking at the type level, youâre managing it yourself.
Unix pipes? Theyâre the OG:
cat file.txt | grep "pattern" | sort | uniq -c
Text streams, process isolation, parallel execution. These concepts influenced many modern functional programming approaches, including Effectâs composable operations.
Advanced patterns worth stealing
The service layer pattern works well for organizing Effect code:
const userService = {
create: (userData: UserData) =>
pipe(
Schema.decodeUnknown(UserSchema)(userData),
Effect.andThen(checkEmailUniqueness),
Effect.andThen(hashPassword),
Effect.flatMap(saveToDatabase),
Effect.tap(sendWelcomeEmail),
Effect.catchTags({
ParseError: () => Effect.fail(new ValidationError("Invalid user data")),
DatabaseError: () => Effect.fail(new ServiceUnavailable())
})
)
}
Each method is a pipeline. Errors bubble up with proper types. Testing? Swap the database layer:
const testLayer = Layer.succeed(DatabaseService, {
save: () => Effect.succeed({ id: "test-id" }),
find: () => Effect.succeed(null)
})
const result = pipe(
userService.create(userData),
Effect.provide(testLayer),
Effect.runPromise
)
The retry scheduler pattern prevents naive retry loops:
const smartRetry = pipe(
Schedule.exponential(Duration.seconds(1)),
Schedule.jittered,
Schedule.whileOutput(Duration.lessThanOrEqualTo(Duration.minutes(1))),
Schedule.tapOutput(duration =>
Effect.log(`Retrying after ${duration.seconds} seconds`)
)
)
Exponential backoff with jitter, maximum duration, and logging. Try implementing that with Promises.
When Effectâs pipe actually helps (and when it doesnât)
Effect shines when you have:
- Complex error scenarios that need explicit handling
- Multiple async operations that might fail independently
- Business logic requiring retries, timeouts, and fallbacks
- Team members who keep shipping bugs because âwe forgot to handle that errorâ
Skip Effect when youâre:
- Building a simple CRUD app with predictable failures
- Working with a team hostile to functional programming
- Prototyping something youâll throw away next week
The migration path that actually works
Teams succeeding with Effect donât rewrite everything. They start at the boundaries:
// Old Promise-based code
async function fetchUserData(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('User not found')
return await response.json()
} catch {
return null
}
}
// Gradual Effect adoption
const fetchUserData = (id: string) =>
pipe(
Effect.tryPromise({
try: () => fetch(`/api/users/${id}`),
catch: () => new NetworkError()
}),
Effect.filterOrFail(
response => response.ok,
() => new UserNotFoundError()
),
Effect.andThen(response =>
Effect.tryPromise({
try: () => response.json(),
catch: () => new ParseError()
})
),
Effect.andThen(Schema.decodeUnknown(UserSchema))
)
Now errors are explicit. The compiler catches missing error handling. The team gradually learns Effect patterns without a big-bang rewrite.
The philosophical shift
Effectâs pipe isnât just about chaining functions. Itâs about making the implicit explicit. Every function in a pipeline must declare what it needs, what it returns, and what can go wrong.
Traditional error handling hides failure:
try {
const user = await getUser(id)
const profile = await getProfile(user.id)
return await enrichProfile(profile)
} catch (error) {
console.error('Something went wrong:', error)
return null
}
What failed? Who knows. Effect forces honesty:
pipe(
getUser(id), // Effect<User, UserNotFoundError, never>
Effect.andThen(u => getProfile(u.id)), // Effect<Profile, ProfileError, never>
Effect.andThen(enrichProfile), // Effect<RichProfile, EnrichmentError, never>
Effect.catchTags({
UserNotFoundError: () => Effect.succeed(guestProfile),
ProfileError: (e) => Effect.fail(new IncompleteDataError()),
EnrichmentError: () => Effect.succeed(basicProfile)
})
)
Every failure mode is visible. Every recovery strategy is explicit. The types tell the whole story.
Type inference that doesnât make you cry
Effect preserves types through insanely long pipelines:
const complexPipeline = pipe(
Effect.succeed({ name: "Alice", age: 30 }),
Effect.map(user => ({ ...user, id: generateId() })),
Effect.flatMap(user =>
user.age >= 18
? Effect.succeed(user)
: Effect.fail(new UnderageError())
),
Effect.andThen(user => fetchPremiumStatus(user.id)),
Effect.map(status => status.isPremium ? "premium" : "basic"),
Effect.retry({ times: 3 }),
Effect.timeout(5000)
)
// Type: Effect<"premium" | "basic", UnderageError | FetchError | TimeoutException, never>
Twenty transformations deep? Types still flow. Compare this to Promise chains where youâre adding type annotations every other line just to keep TypeScript happy.
The ecosystem bonus
When you buy into Effectâs pipe, you get an entire ecosystem designed around it:
// HTTP client with built-in Effect support
const userData = pipe(
HttpClientRequest.get("/api/user"),
HttpClient.execute,
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(UserSchema)),
Effect.retry({ times: 3 }),
Effect.provide(FetchHttpClient.layer)
)
// Stream processing
const processedStream = pipe(
Stream.fromIterable(largeDataset),
Stream.map(processItem),
Stream.filter(isValid),
Stream.groupByKey(item => item.category),
Stream.runCollect
)
Everything speaks the same language. Everything composes the same way.
Performance tricks
Memoization for expensive operations:
const expensiveCalculation = pipe(
Effect.sync(() => {
console.log("This only runs once!")
return heavyComputation()
}),
Effect.cached
)
// Multiple calls, single execution
Effect.all([
expensiveCalculation,
expensiveCalculation,
expensiveCalculation
])
Batching for database operations:
const getUserById = (id: string) =>
pipe(
Effect.request(GetUserById(id)),
Effect.flatMap(Schema.decodeUnknown(UserSchema))
)
// Automatically batches multiple getUserById calls
const users = Effect.all([
getUserById("1"),
getUserById("2"),
getUserById("3")
])
The gotchas nobody talks about
Effect.gen looks tempting for developers coming from async/await:
const program = Effect.gen(function* () {
const user = yield* getUser(id)
const profile = yield* getProfile(user.id)
return yield* enrichProfile(profile)
})
But overusing generators defeats the purpose. You lose the composability that makes pipe powerful. Save generators for complex control flow, not simple sequences.
Another gotcha: nested pipes get ugly fast:
// Don't do this
pipe(
data,
x => pipe(
x,
transform1,
y => pipe(
y,
transform2,
z => pipe(z, transform3)
)
)
)
Flatten it out. Extract functions. Keep pipes linear.
Effectâs pipe function represents a philosophical shift: stop pretending errors donât exist. Stop hiding dependencies. Make everything explicit, and let the compiler help you build robust systems. Itâs not always easy, but for complex applications, itâs a compelling choice for many complex applications.