Effect.flatMap
In a previous lesson we saw how Effect.map can transform an Effect into another Effect, by applying a regular function.
What happens when we try to map over an Effectful function?
import { Effect } from "effect";
class YouLose extends Error {}
// ┌─── Effect.Effect<number, never, never>
// ▼
const random = Effect.sync(() => Math.random());
const toDiceRoll = (v: number) => Math.ceil(v * 6);
// this is fine, because toDiceRoll returns a plain value
// ┌─── Effect.Effect<number, never, never>
// ▼
const rolled = Effect.map(random, toDiceRoll);
// what happens when the function we want to map over is Effectful?
const isWinner = (roll: number): Effect.Effect<string, YouLose> =>
roll >= 5
? Effect.succeed("you win!")
: Effect.fail(new YouLose("better luck next time"));
// ┌─── Effect.Effect<Effect.Effect<string, YouLose, never>, never, never>
// ▼
const program = Effect.map(rolled, isWinner);
// ┌─── Effect.Effect<string, YouLose, never>
// ▼
const result = Effect.runSync(program);
console.log(result);
We end up with nested effects: an Effect inside an Effect. This is exactly the same nesting behaviour you would get with an Array. Just like with Arrays, we can apply a “flat map”, to avoid the nesting.
// ┌─── Effect.Effect<string, YouLose, never>
// ▼
const program = Effect.flatMap(rolled, isWinner);
// ┌─── string
// ▼
const result = Effect.runSync(program);
console.log(result);
The previous example shows flatMap being used for chaining operations together. We perform one operation and then we perform the next one in the chain. In plain English we might think of the steps as
- generate a random number
- and then convert it to a dice roll
- and then figure out if the player won or not
Note that we can use Effect.map
for converting to a dice roll, because this is a pure calculation, which returns a plain value. We need to use Effect.flatMap
for figuring out if the player won, because this is Effectful.
Similarly, we can use Effect.flatMap
for combining the results of two operations.
// oops, nested Effect
const rollTwoDice = Effect.map(rolled, (r1) =>
Effect.map(rolled, (r2) => {
return r1 + r2;
}),
);
Using Effect.flatMap
here will continue to let us “peek inside” both effects to access the numbers r1
and r2
. But we also need to take care with our return type: Effect.flatMap
expects us to return an Effect, and r1 + r2
is a number.
const rollTwoDice = Effect.flatMap(rolled, (r1) =>
Effect.flatMap(rolled, (r2) => {
return Effect.succeed(r1 + r2)
}),
);
We can use map and flatMap together. Indeed, in the above implementation only the outer effect needs to be a flatMap
.
const rollTwoDice = Effect.flatMap(rolled, (r1) =>
Effect.map(rolled, (r2) => {
return r1 + r2
}),
);
console.log(Effect.runSync(rollTwoDice));
You will often find when writing code with Effect that there are multiple ways to implement the same logic. This is fine. Pick whichever approach works best for you.
This second implementation is shorter, so you may find it easier to read. I find the version with two flatMaps easier to read, because it makes it more obvious that the whole operation treats r1
and r2
the same. That being said, both implementations look quite complicated, especially given all we are trying to do is add two numbers together. In future lessons we will see ways to make the code easier to read.
In future lessons we will also see better ways to deal with combining Effects. The Effect library, like many functional programming libraries, offers a large number of combinators. A combinator is a function that takes multiple arguments of type T
and returns a new structure containing one “combined” T
. Don’t be put off by the fancy name. You’ve worked with combinators before, even if you didn’t call them that:
// a combinator of numbers
const max = (a: number, b: number): number => b > a ? b : a
// a combinator of booleans
const or = (a: boolean, b: boolean): boolean => a || b
// a combinator of strings
const concat = (a: string, b: string): string => `${a}${b}`
// a combinator of Arrays
const longer = <T>(a: T[], b: T[]): T[] => b.length > a.length ? b : a
// a combinator of Ts
const first = <T>(a: T, b: T): T => a
Note that it is possible to write Effect.map
in terms of Effect.flatMap
.
const map = <A, B>(self: Effect.Effect<A>, fn: (a: A) => B) =>
Effect.flatMap(self, (a: A) => Effect.succeed(fn(a)));
// ┌─── Effect.Effect<"HEADS" | "TAILS">
// ▼
const flip = map(random, (x) => (x > 0.5 ? "HEADS" : "TAILS"));
// ┌─── "HEADS" | "TAILS"
// ▼
const flipped = Effect.runSync(flip);
This is not the exact implementation of map
in the Effect library, but understanding that the two functions are conceptually related can be helpful.
It’s important to note that Effect.flatMap
does not impact the errors. By extension, Effect.map
does not impact the errors either.
Let’s take a look at one more common use case for Effect.flatMap
: validation.
Effect.flatMap
has a dual API, just like Effect.map
. Let’s see how Effect.flatMap
can transform a validation operation.
class InvalidString extends Error {
_kind = "InvalidString" as const;
}
class InvalidUrl extends Error {
_kind = "InvalidUrl" as const;
}
const isString = (x: unknown): Effect.Effect<string, InvalidString> =>
typeof x === "string"
? Effect.succeed(x)
: Effect.fail(new InvalidString(`${x} is not a string`));
const isUrl = (x: string): Effect.Effect<URL, InvalidUrl> => {
try {
return Effect.succeed(new URL(x));
} catch (e) {
return Effect.fail(new InvalidUrl(`${x} is not a URL`));
}
};
const isStringE = Effect.flatMap(isString);
const isUrlE = Effect.flatMap(isUrl);
const submittedData = Effect.succeed("https://effect.website");
const validator = isUrlE(isStringE(submittedData));
console.log(Effect.runSync(validator));
Observe that the validator will fail if any of the steps fail.
// const after0 = Effect.succeed(3);
// const after1 = Effect.flatMap(after0, isString); // Effect.Effect<string, InvalidString>
const after1 = Effect.fail(new InvalidString(`3 is not a string`)) // Effect.Effect<never, InvalidString>
const after2 = Effect.flatMap(after1, isUrl); // Effect.Effect<URL, InvalidString | InvalidUrl>
console.log(Effect.runSync(after2));
If the first validation step fails, then we do not even attempt to run the second step. Instead, the failure is passed through unchanged. This makes sense, just from looking at the type signature. If the first step fails, then how could Effect.flatMap
produce an Effect.Effect<URL, InvalidString | InvalidUrl>
using only after1
and isUrl
? isUrl
requires a string as a parameter, but we have no way of getting a string from after1
, so we must ignore it.
Let’s recap.
- Effect-the-module contains functions for constructing, transforming, combining and running Effects.
- Effect.flatMap has a dual function signature, for convenience.
- Effect.flatMap(effect, fn) transforms an Effect into another Effect by lazily applying an Effectful function.
- Effect.flatMap(fn) transforms an Effectful function that takes a plain argument into an Effectful function that takes an Effect argument.
- Effectful functions are composable.
- Effect.flatMap(fn)(effect) and Effect.flatMap(effect, fn) are equivalent.
- Effect.map can be written in terms of Effect.flatMap and Effect.succeed.
Bonus exercise: try to write code that would add four random dice rolls together.
Possible solution
// more obvious?
const addE = (e1: Effect.Effect<number>) => (e2: Effect.Effect<number>) =>
Effect.flatMap(e1, (v1) =>
Effect.flatMap(e2, (v2) => {
return Effect.succeed(v1 + v2);
}),
);
const addRoll = addE(rolled)
const rollFourDice = addRoll(addRoll(addRoll(addRoll(Effect.succeed(0)))))