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:

  1. 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
  2. A way to determine whether you’re calling the data-first or data-last flavor of the function
  3. 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`))
	)
    
Note

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