Skip to content

Dual APIs

Most combinators in Effect — Effect.map, Effect.flatMap, Stream.filter, Stream.map, and many more — are dual: they can be called in two equivalent ways. You can pass the data first and call the function directly, or pass the data last and use the function as a step inside pipe. Understanding both forms lets you read any Effect code fluently and pick the most readable form for each situation.

import { Effect, pipe } from "effect"
const double = (n: number) => n * 2
const effect = Effect.succeed(21)
// data-first: the Effect is the first argument — no pipe needed.
const a = Effect.map(effect, double)
// data-last: the Effect is supplied last, by pipe.
const b = pipe(effect, Effect.map(double))
// `.pipe` on the Effect itself is the idiomatic form of data-last.
const c = effect.pipe(Effect.map(double))

All three produce the same Effect<number>. The difference is purely ergonomic.

The terms “data-first” and “data-last” describe where the self argument (the data the function operates on) sits. A dual function has both overloads:

import type { Effect } from "effect"
declare const map: {
// ┌─── data-last (used by pipe)
// ▼
<A, B>(f: (a: A) => B): <E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<B, E, R>
// ┌─── data-first (called directly)
// ▼
<A, E, R, B>(self: Effect.Effect<A, E, R>, f: (a: A) => B): Effect.Effect<B, E, R>
}
  • The data-last overload takes only the transformation and returns a function awaiting the data. That returned function is exactly what pipe feeds the data into.
  • The data-first overload takes the data and the transformation together and returns the result immediately.

At runtime a single implementation handles both, dispatching on the number of arguments it receives.

Use the form that reads best for the situation.

When you apply several transformations in sequence, data-last with .pipe keeps each step on its own line and reads top to bottom:

import { Effect } from "effect"
const program = Effect.succeed(1).pipe(
Effect.map((n) => n + 1),
Effect.map((n) => n * 10),
Effect.tap((n) => Effect.log(`value is ${n}`))
)

Generators give you a third option that frequently beats both: bind intermediate results with yield* and write ordinary expressions. There is no pipe and no nesting.

import { Effect } from "effect"
const program = Effect.gen(function*() {
const n = yield* Effect.succeed(1)
const doubled = n * 2
yield* Effect.log(`value is ${doubled}`)
return doubled
})

Use yield* for the linear flow; drop into .pipe only when a single value needs a chain of combinators applied to it.

When you build a reusable combinator over your own data type, make it dual with Function.dual so callers get both forms for free. The first argument tells dual how to recognize a data-first call — either the data-first arity, or a predicate over the arguments.

import { Function, pipe } from "effect"
// Arity 2 = the data-first overload takes two arguments.
const sum = Function.dual<
(that: number) => (self: number) => number, // data-last
(self: number, that: number) => number // data-first
>(2, (self, that) => self + that)
sum(2, 3) // data-first → 5
pipe(2, sum(3)) // data-last → 5