Monads Part 2: The Effect Dual API
A series where I discuss how monads are at the core of Effect.

1. Introduction
One of the core ideas in functional programming is the ability to sequence computations in a predictable and composable way. Instead of scattering if/else checks or deeply nested callbacks through your code, you can build transformations step by step—each function describing what to do next.
Many libraries solve this by attaching methods directly onto “container” types (for example, a map method on an array or a then method on a promise). This lets you write chains like myValue.map(f).flatMap(g).
The Effect library takes a different approach. Instead of methods, it provides functions such as map and flatMap that work with the Effect type, but are not attached to it. This choice is deliberate: it keeps the core type minimal, makes the API more modular, and improves things like tree-shaking and extensibility.
To make these functions ergonomic, Effect introduces the Dual API: every operator can be used in both data-first and data-last styles. This means you can write either map(effect, f) or use a pipeline style like pipe(effect, map(f)). The latter works because of a concept called currying, where a function that doesn’t receive all its arguments immediately returns a new function waiting for the rest.
In this post, we’ll explore how the Dual API works, why currying matters, and how it connects to the bigger picture of monads and sequencing. Along the way, we’ll use a small example monad implementation to see how map and flatMap work in practice, and compare three different ways of writing the same computation.
2. Background: Monads and Sequencing
At its core, a monad is just a design pattern for handling values that come wrapped in some kind of context. That context could mean many things:
an
Optionmight represent “a value that might not exist,”a
Promiserepresents “a value that will be available in the future,”an
Effectrepresents “a computation that could fail, succeed, or depend on resources.”
What makes monads useful is that they define two essential operators (aka combinators):
map — apply a function to the inner value, keeping it wrapped in the same context.
flatMap (sometimes called
chainorbind) — apply a function that itself returns another wrapped value, and then flatten the result into a single layer.
With just these two tools, we can sequence computations: each step can transform or expand on the result of the previous one, without ever needing to “unwrap” the value ourselves.
In many libraries, these are implemented as methods:
Array.of(3) // [3]
.map(x => x + 1) // [4]
.flatMap(x => [x-1, x, x + 1]) // [3, 4, 5]
But in Effect, where the Effect type is a monad, map and flatMap are not methods on the Effect type. Instead, they’re standalone functions that take an Effect as input. This design choice makes the library more flexible and consistent across different data types, since the same map function exists for any monad-like data structure in Effect.
Effect.map(Effect.succeed(3), (x) => x + 1)
Option.map(Option.some(3), (x) => x + 1)
Either.map(Either.right(5), (n) => n + 1)
Exit.map(Exit.succeed(10), (n) => n + 1)
A downside of this though, is that the nice method chaining syntax is no longer available to us. Let’s for a second, imagine that map and flatMap were not methods on the JavaScript Array object, but instead were standalone functions. If we re-wrote our Array chaining code from above with map and flatMap as functions, it would look like this:
const result = flatMap(
map(
Array.of(3),
(x) => x + 1 // like map
),
(x) => [x-1, x, x + 1] // like flatMap
)
// or all on one line:
const result = flatMap(map(Array.of(3), (x) => x + 1), (x) => [x-1, x, x + 1])
In both styles, notice how we are forced to read the operations in reverse-order of execution.
We read left-to-right:
flatMap->map->Array.ofbut the code executes right-to-left (from inner to outer parentheses)
Array.of->map->flatMap
I think we can all agree that the above code is both hard to understand and unpleasant to read vs the more naturally flowing method chaining.
So, the next question is: how do we compose these function calls in a way that feels natural? This is where pipe comes in.
3. Pipeable Functions in Effect
To solve the readability problem of nested function calls, Effect provides a simple helper called pipe. The idea of pipe is to let us compose operations into a pipeline, in the same order that we read them: left to right. Instead of wrapping function calls inside one another, we start with a value and then pass it through a series of transformations.
Lets’ replace Array with Effect, and see what our earlier nested example looks like with pipe:
import { Effect, pipe } from "effect"
const result = pipe(
Effect.succeed(3),
Effect.map((x) => x + 1),
Effect.flatMap((x) => Effect.succeed(x * 2))
)
This is much easier to follow:
start with an
Effectthat contains3,map it to
4,then flatMap into a new effect that doubles the value, producing
8.
Notice the contrast when not using pipe:
const result = Effect.flatMap(
Effect.map(
Effect.succeed(3),
(x) => x + 1),
),
(x) => Effect.succeed(x * 2),
)
Both snippets do the same thing. The difference is in style, and is possible due to how Effect.map and Effect.flatMap behave based on the number of arguments they are passed.
when
mapis called with 2 arguments, egmap(effect, f), it is considered data-first, but tends to read inside-out when used in a sequence of steps.when
mapis called with 1 argument and used withpipe,pipe(effect, map(f)), it is considered data-last, and the resulting code reads top-to-bottom, left-to-right when combined with a sequence of combinators inside pipe.
This small difference is what makes pipelines feel so natural. With pipe, we can write our effectful code in a way that matches how we describe it in words: “take this, then do that, then do the next thing.”
But how does map know whether it should run in data-first or data-last mode? The answer lies in Effect’s Dual API, which uses currying to make both styles possible
4. What is the Dual API?
The Dual API is a design pattern in Effect that gives every operator two overloads: data-first and data-last. This flexibility means you don’t have to choose one style up front—you get both, depending on what works best for the situation.
Let’s look at map as an example.
Data-first:
Effect.map(Effect.succeed(3), (x) => x + 1)Here, we give
mapboth the effect and the transformation at once. The function runs immediately and produces a newEffect.Data-last:
pipe( Effect.succeed(3), Effect.map((x) => x + 1) )In this case, we only pass the transformation to
map. Instead of executing,mapreturns a new function waiting for the effect. Whenpipesupplies that effect, everything composes neatly left-to-right.
This “return a new function if not all arguments are provided” behaviour is possible thanks to currying. Currying is the process of turning a multi-argument function into a sequence of single-argument functions.
In other words, instead of a map function that takes 2 arguments (effect, f) => …,
we use currying to transform map into two unary (single-argument) functions that behave like (f) => (effect) => ….
The beauty of the Dual API is that you don’t need two different functions for two different styles. With a single definition, Effect gives you both. That’s why you can freely choose between:
// data-first
Effect.map(effect, f)
// data-last (curried)
pipe(effect, Effect.map(f))
In practice, the data-last style feels more natural when sequencing many steps, while data-first can be convenient for quick one-off calls. Together, the Dual API ensures you get both readability and flexibility without compromise.
5. Minimal Example
To make all of this more concrete, let’s build a tiny “toy” monad ourselves. It won’t have all the power of Effect, but it will be just enough to demonstrate how map, flatMap, and the Dual API fit together. I will write it in plain JavaScript, as the point is to focus on the logic inside flat and flatMap to understand how they work
class Monad {
constructor(value) {
this.value = value
}
static of(value) {
return new Monad(value)
}
}
// map (dual API: 1 arg returns a function, 2 args executes directly)
function map(aOrFn, maybeFn) {
if (typeof aOrFn === "function") {
// data-last: return a new function waiting for the monad
const fn = aOrFn
return (self) => Monad.of(fn(self.value))
} else {
// data-first: apply immediately
const self = aOrFn
const fn = maybeFn
return Monad.of(fn(self.value))
}
}
// flatMap (same dual pattern)
function flatMap(aOrFn, maybeFn) {
if (typeof aOrFn === "function") {
const fn = aOrFn
return (self) => fn(self.value)
} else {
const self = aOrFn
const fn = maybeFn
return fn(self.value)
}
}
// a small helper like Effect.pipe
function pipe(value, ...fns) {
return fns.reduce((acc, fn) => fn(acc), value)
}
With this setup, we can now see two different ways of sequencing computations:
function addOne(x) {
return x + 1
}
function double(x) {
return Monad.of(x * 2)
}
// ✅ Effect-style data-last
const r1 = pipe(
Monad.of(13),
map(addOne), // Monad(14)
flatMap(double) // Monad(28)
)
// ✅ Data-first
const r2 = flatMap(map(Monad.of(3), addOne), double)
console.log("pipe:", r1) // Monad { value: 28 }
console.log("data-first:", r2) // Monad { value: 8 }
Each version runs the same logical steps:
Start with a number wrapped in a
Monad.Apply
mapto increment it.Apply
flatMapto double it inside a new monad.
The difference is purely in style:
Data-first: explicit function calls, but nested.
Data-last with pipe: clean, top-to-bottom flow.
So far, looked at the standalone combinators map, flatMap, and pipe. This mirrors how the Effect API is designed: everything is a function, not a method.
6. Adding a Method-Style Pipe
Interestingly, Effect does actually make one small exception: it gives the Effect type a single method — pipe. This is purely for convenience. It lets you write effectful code in a style that feels natural if you like method chaining.
We can add the same idea to our toy monad:
class Monad {
constructor(value) {
this.value = value
}
static of(value) {
return new Monad(value)
}
// method-style pipe for convenience
pipe(...fns) {
return fns.reduce((acc, fn) => fn(acc), this)
}
}
Now we can write a third version of our computation:
const r3 = Monad.of(5).pipe(
map(addOne),
flatMap(double)
)
console.log("method-pipe:", r3) // Monad { value: 12 }
This method-based style is equivalent to using the standalone pipe helper, but sometimes it feels more natural because it reads like “dot-chaining.”
Effect includes this exact shortcut: even though its operators are functions, the Effect type exposes a .pipe() method so you can choose whichever style you prefer—data-first, data-last with pipe, or method-style chaining.
7. Three Ways to Sequence Monads
At this point, we’ve seen three distinct ways to express the same sequence of computations using our toy monad. Each one produces the same result, but they differ in style and readability.
function addOne(x) {
return x + 1
}
function double(x) {
return Monad.of(x * 2)
}
Data-first
Explicit function calls, but reads inside-out because of nesting.const r1 = flatMap( map(Monad.of(3), addOne), double ) // Monad { value: 8 }Data-last with pipe
The most common Effect style. Reads top-to-bottom, left-to-right.const r2 = pipe( Monad.of(13), map(addOne), flatMap(double) ) // Monad { value: 28 }Method-style pipe
Same as data-last, but uses a.pipe()method for dot-chaining convenience.const r3 = Monad.of(5).pipe( map(addOne), flatMap(double) ) // Monad { value: 12 }
All three approaches describe the same logical process:
Start with a monad that wraps a number.
Increment the number with
map.Double it inside a new monad with
flatMap.
The choice of style is up to you:
Data-first is explicit but can become unreadable in longer chains.
Data-last with pipe is the idiomatic Effect style and scales best for sequencing multiple steps.
Method-style pipe is a convenience option for those who like dot-chaining.
This flexibility is the payoff of the Dual API: with a single implementation of map and flatMap, we get multiple ergonomic ways to write effectful code.
7. Conclusion
We started by looking at how monads give us a structured way to sequence computations, and how many libraries implement this with methods like .map and .flatMap. Effect takes a different path: instead of methods, it provides standalone functions. This design keeps the library lightweight, modular, and consistent across different data types.
To make those functions ergonomic, Effect uses the Dual API: every operator can work in either data-first or data-last style. Data-first calls are explicit but can become cumbersome in chains, while data-last calls—especially when combined with pipe—read top-to-bottom in a way that matches how we naturally describe our programs. And for convenience, Effect also provides a single method, .pipe(), so you can write in a dot-chaining style if you prefer.
With just this small set of ideas—map, flatMap, pipe, and the Dual API—you can write code that is both expressive and highly composable. The same logic can be written three different ways, each equally valid. That flexibility is part of what makes Effect such a powerful tool: it gives you the building blocks to express your programs clearly, without forcing you into a single style.
In short: the Dual API is not just a quirk of the library, but a deliberate design choice that balances flexibility with readability, and turns functional patterns like monads into a practical, everyday tool for writing real-world applications.
References:
Effect Docs - Getting Started - Building Pipelins - Functions vs Methods

