We can zip two Effects together to produce an Effect whose result in the combined result. Essentially this “swaps” the types: we switch from a pair of Effects to an Effect of a pair.
Let’s see a basic example.
import { Effect } from "effect";import { DiceService, CoinService } from "./lib";
// ┌─── Effect.Effect<// | [number, Coin],// | never,// | DiceService | CoinService// | >// ▼const zipped = Effect.zip(DiceService.roll, CoinService.flip);
zipped.pipe(Effect.map(([dice, coin]) => `rolled ${dice} and flipped ${coin}`));
When they are simple synchronous calls, this saves us a few keystrokes over using Effect.flatMap
(6)
, but is not much of a big deal. Where it really comes in handy is when the effects are asynchronous. Consider this simple HTTP client.
import { Data } from "effect";
class HttpError extends Data.TaggedError("@fred.c/HttpError")<{ cause: unknown;}> {}
const httpGet = (url: string) => Effect.tryPromise({ try: () => fetch(url), catch: (cause) => new HttpError({ cause }), }).pipe( Effect.tryMapPromise({ try: (res) => res.text(), catch: (cause) => new HttpError({ cause }), }) );
const allPages = Effect.zip( httpGet("https://effect.website"), httpGet("https://example.com"), { concurrent: true }).pipe(Effect.map(([effect, example]) => `loaded ${effect} and ${example}`));
Both requests are made concurrently, whereas with Effect.flatMap
(6)
we would necessarily run the requests in sequence, so that the second effect may use the result of the first.
By default, Effect.zip
will run sequentially. This helps with predictability.
It is important to note that there is a difference between things running concurrently and things running in parallel. JavaScript is single-threaded by design, which means only one processor thread is executing one code instruction at a time. But many asynchronous functions can execute concurrently, with the thread running other instructions while one function is waiting for asynchronous results to be available. This is not specific to Effect - it is the same as how Promises work.
We can implement a basic (sequential) version of Effect.zip
using Effect.flatMap
(6)
, but the nesting makes it slightly cryptic. The version using Effect.gen
(15)
is simpler to read.
type Zip = <A, B, E1, R1, E2, R2>( self: Effect.Effect<A, E1, R1>, that: Effect.Effect<B, E2, R2>) => Effect.Effect<[A, B], E1 | E2, R1 | R2>;
const zip: Zip = (self, that) => Effect.flatMap(self, (a) => Effect.flatMap(that, (b) => Effect.succeed([a, b])) );
const zipGen: Zip = (self, that) => Effect.gen(function* () { const a = yield* self; const b = yield* that; return [a, b]; });
Note that the signature for the zip operation shows a lot of symmetry between the first and second arguments. In the implementation above we ran the first effect first, followed by the second effect, but there is nothing about the type signature that forces this. We could just as easily flip the order of execution.
const zipReverse: Zip = (self, that) => Effect.flatMap(that, (b) => Effect.flatMap(self, (a) => Effect.succeed([a, b])) );
It is this symmetry that means we can run the operations concurrently. The concurrency is an implementation detail, a form of optimisation that does not affect the end result of our programs, in general. In production we may want high concurrency in order to maximise throughput and minimise bottlenecks. In our tests we may want to disable concurrency in order to reproduce specific edge cases or race conditions.
This is a common theme in Effect code. We should be able to opt in or out of concurrency as we please. Effect has a powerful fiber-based concurrency model that offers us many options to control how many effects can run concurrently. We will see more of this in future lessons.
Let’s recap:
- There are many combinators on top of Effect
- Most of these can theoretically be built in terms of
Effect.flatMap
(6) Effect.zip
converts a pair of Effects into an Effect of a pairEffect.zip
can run two Effects concurrently