Effect.map
So far we have seen how to construct an Effect, and how to run an Effect. We have seen that an Effect, once constructed, is immutable. In this lesson, we’ll use Effect.map
to transform an Effect.
Let’s take a basic program
const double = (x: number) => x * 2
const three = 3
const six = double(three)
console.log(six) // --> 6
Suppose our initial value is not a plain value, but a value inside an Effect. The code will no longer work. double
accepts a number, not an Effect.
const three = Effect.succeed(3)
const six = double(three) // Argument of type 'Effect<number, never, never>' is not assignable to parameter of type 'number'.
Using Effect.map
we can take a regular function and apply it to an Effect.
import { Effect } from 'effect'
const double = (x: number) => x * 2
const three = Effect.succeed(3)
const six = Effect.map(three, double)
console.log(Effect.runSync(six)) // --> 6
As before, we can see that this implementation is lazy. The calculation 3 * 2
will only happen when we run the Effect.
import { Effect } from 'effect'
const double = (x: number) => {
const result = x * 2
console.log(`CALCULATING: ${x} * 2 = ${result}`)
return result
}
console.log("1. creating an effect")
const three = Effect.succeed(3)
console.log("2. mapping an effect")
const six = Effect.map(three, double)
console.log("3. running an effect")
Effect.runSync(six)
When we run the code, we see that the CALCULATING
line only appears when we run the Effect, not when we define it.
$ npx tsx main.ts
1. creating an effect
2. mapping an effect
3. running an effect
CALCULATING: 3 * 2 = 6
We are not limited to functions on numbers.
import { Effect } from 'effect'
const double = (x: number) => x * 2
const isBig = (x: number) => x > 10 ? `${x} is big` : `${x} is small`
const three = Effect.succeed(3)
const six = Effect.map(three, double)
const twelve = Effect.map(six, double)
const isTwelveBig = Effect.map(twelve, isBig)
console.log(Effect.runSync(isTwelveBig)) // --> 12 is big
Note that Effect knows what type isTwelveBig
should be.
const isTwelveBig: Effect.Effect<string, never, never>
Looking at the signature of Effect.map
on that line, we can see how this is possible.
const map: <number, never, never, string>(self: Effect.Effect<number, never, never>, f: (a: number) => string) => Effect.Effect<string, never, never> (+1 overload)
Effect.map
has two different implementations, as hinted by the (+1 overload)
. This is a common pattern in the Effect library, and is known as a “dual” signature. As we will see, both forms are very convenient. Exposing the overloaded functions saves having to learn two different names for every function.
So far we have thought of Effect.map
as applying a single function to a single Effect, but we can also treat it as a way of transforming a function.
import { Effect } from 'effect'
const double = Effect.map((x: number) => x * 2)
const isBig = Effect.map((x: number) => x > 10 ? `${x} is big` : `${x} is small`)
const three = Effect.succeed(3)
const six = double(three)
const twelve = double(six)
const isTwelveBig = isBig(twelve)
console.log(Effect.runSync(isTwelveBig)) // --> 12 is big
When we apply Effect.map(f)
over some function, we get back a new function. If the original function converts number
s to string
s, then this new function converts Effect<number>
s to Effect<string>
s. We can call this an “Effectful” function. For example
const isBig: <E, R>(self: Effect.Effect<number, E, R>) => Effect.Effect<string, E, R>
Behaviourally, Effect.map(program, fn)
is the same as Effect.map(fn)(program)
.
A function that accepts one function and returns a function is known as a higher-order function. Effect.map
is the first higher-order function we have seen, but there will be many more.
Note that we can simplify the above code, to look more like a “normal” program. Effectful functions produced by Effect.map(fn)
compose just like regular functions. That’s because they are regular functions. The only difference is that the inputs and output types are Effects.
import { Effect } from 'effect'
const double = Effect.map((x: number) => x * 2)
const isBig = Effect.map((x: number) => x > 10 ? `${x} is big` : `${x} is small`)
const three = Effect.succeed(3)
const isTwelveBig = double(double(isBig(three)))
console.log(Effect.runSync(isTwelveBig)) // --> 12 is big
Let’s revisit our model of what an Effect is. Let’s see what happens when we log the Effect without running it.
import { Effect } from "effect";
const double = Effect.map((x: number) => x * 2);
const isBig = Effect.map((x: number) =>
x > 10 ? `${x} is big` : `${x} is small`,
);
const three = Effect.succeed(3);
const six = double(three);
const twelve = double(six);
const isTwelveBig = isBig(twelve);
console.log("three is", three);
console.log("six is", six);
console.log("twelve is", twelve);
console.log("isTwelveBig is", isTwelveBig);
We can see that as we build up increasingly nested function calls, we also build an increasingly nested Effect data structure.
$ npx tsx main.ts
three is { _id: 'Exit', _tag: 'Success', value: 3 }
six is {
_id: 'Effect',
_op: 'OnSuccess',
effect_instruction_i0: { _id: 'Exit', _tag: 'Success', value: 3 },
effect_instruction_i1: [Function (anonymous)],
effect_instruction_i2: undefined
}
twelve is {
_id: 'Effect',
_op: 'OnSuccess',
effect_instruction_i0: {
_id: 'Effect',
_op: 'OnSuccess',
effect_instruction_i0: { _id: 'Exit', _tag: 'Success', value: 3 },
effect_instruction_i1: [Function (anonymous)],
effect_instruction_i2: undefined
},
effect_instruction_i1: [Function (anonymous)],
effect_instruction_i2: undefined
}
isTwelveBig is {
_id: 'Effect',
_op: 'OnSuccess',
effect_instruction_i0: {
_id: 'Effect',
_op: 'OnSuccess',
effect_instruction_i0: {
_id: 'Effect',
_op: 'OnSuccess',
effect_instruction_i0: [Object],
effect_instruction_i1: [Function (anonymous)],
effect_instruction_i2: undefined
},
effect_instruction_i1: [Function (anonymous)],
effect_instruction_i2: undefined
},
effect_instruction_i1: [Function (anonymous)],
effect_instruction_i2: undefined
}
We can see more clearly how an Effect conceptually represents a computer program. The structure of the Effect mirrors the structure of the program it represents. The Effect data structure looks like a tree. A larger Effect tree can be composed from smaller Effect trees, for example by composing function calls. It’s composition all the way down.
Let’s recap.
- Effect-the-module contains functions for constructing, transforming and running Effects.
- Effect-the-data-type is an in-memory representation of a program.
- To run the program and obtain an output value, you must explicitly run the Effect.
- Effect.map has a dual function signature, for convenience.
- Effect.map(effect, fn) transforms an Effect into another Effect by lazily applying a function.
- Effect.map(fn) transforms a plain function into an Effectful function.
- Effectful functions are composable.
- Effect.map(fn)(effect) and Effect.map(effect, fn) are equivalent.