Writing dual APIs with Effect
Most functions in Effect have a dual API, which lets you call them in two forms depending on what’s more ergonomic for your situation.
// Data-first
Effect.map(effect, func)
// Data-last
Effect.map(func)(effect)
The data-last variant is useful because you can use with pipe
to reduce
nesting in longer chains of functions.
// Data-last without pipe
Effect.map(func2)(
Effect.map(func1)(
effect
)
)
// Data-last with pipe
effect.pipe(
Effect.map(func1),
Effect.map(func2),
)
You might find yourself wanting to support a dual API when writing your own functions for improved ergonomics. Internally, most functions in Effect use Function.dual to achieve a type-safe dual API, and you can do the same!
Recipe for a dual API
The dual
function requires a few ingredients to work:
- A couple of function type expressions to specify parameter and return types for both the data-last and data-first variants of your function, passed as generic type arguments
- A way to determine whether you’re calling the data-first or data-last flavor of the function
- A data-first implementation of the function
The result of this is a dual API version of the function you provided, which can translate data-last invocations into a partially applied version of the data-first API.
Determining the API variant
The first argument to dual
is what tells it which API variant you’re calling,
and you have a choice for how to populate this:
- Provide a
number
representing the arity (number of arguments) for the data-first implementation - Provide a
(args: IArguments) => boolean
predicate that returns true if the arguments follow a data-first invocation and false for a data-last invocation
The
vast majority
of dual
usage in the Effect source code go the arity route, so I’d stick with
that approach unless you have reason to use a predicate.
Example
Let’s say we’re writing a withLogAround
function that logs a message before an
after execution of another effect.
const withLogAround = dual<
(label: string) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>,
<A, E, R>(self: Effect.Effect<A, E, R>, label: string) => Effect.Effect<A, E, R>
>(
2,
(self, label) =>
pipe(
Effect.log(`[${label}] start`),
Effect.andThen(() => self),
Effect.tap(() => Effect.log(`[${label}] end`))
)
)
The first type argument describes the data-last API for the function:
(label: string) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
The second type argument describes the data-first API for the function:
<A, E, R>(self: Effect.Effect<A, E, R>, label: string) => Effect.Effect<A, E, R>
The first argument 2
provided to dual
indicates that the data-first API
accepts 2 arguments, an effect and a label. That way when we pass one argument
in the data-last case, it knows to treat it as a label instead of an effect.
The second argument contains our actual implementation of the function in data-first form:
(self, label) =>
pipe(
Effect.log(`[${label}] start`),
Effect.andThen(() => self),
Effect.tap(() => Effect.log(`[${label}] end`))
)
Because we already specified the data-last function type in the generic type arguments, we don’t need to specify them again here.
The resulting function supports a dual API!
// Data-first
withLogAround(Effect.sleep("1 second"), "sleep")
// Data-last
withLogAround("sleep")(Effect.sleep("1 second"))
// Data-last with pipe
Effect.sleep("1 second").pipe(
withLogAround("sleep")
)
Changelog
2025-03-25
This post originally suggested using
call signatures
to specify the two API variants alongside the dual
call based on the example
from the API reference. Max Brown pointed out
on Discord
that this could be optimizied using the generic type arguments to reduce
repetition.
The original playground can be found here: https://effect.website/play#4d68047c2f60