Sid Ngeth's Blog A blog about anything (but mostly development)

Effect's pipe: the backbone of composable TypeScript

You 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.

comments powered by Disqus