Getting Started #
What an Effect is, how to read its type, how to build one, and how to run it.
The problem. A plain TypeScript function hides 3 facts from its caller. Look at this signature:
async function loadUser(id: string): Promise<User>The signature says that the function returns a User. It does not say that the function can throw NotFound or NetworkError. It does not say that the function needs a database connection and a logger. Also, the function starts to run when you call it. Thus you cannot retry it, apply a timeout to it, or run 2 of them in parallel without extra code. The failure, the requirements, and the start of the work are all hidden.
The shift
Today you think of a function as code that does work when you call it. In Effect, a program is a value that describes work. The value does not do the work. You build the value, you combine it with other values, and at the end you give it to the runtime. The runtime does the work.
Because an effect is a value, its type can show all the facts that the plain function hid:
Effect<Success, Error, Requirements>
Effect<User, NotFound | NetworkError, Database | Logger>Read the type as follows: "When you run this effect, it gives a User, or it fails with one of these errors, and it needs these services." The compiler checks all 3 parts. If you do not catch an error, the error stays in the type. If you do not provide a service, the program does not compile.
This design has a benefit in all other sections of this course. Retries, timeouts, concurrency, resource cleanup, and tests all become plain functions. Each function takes an effect and returns a new effect, because an effect is data.
| Promise | Effect | |
|---|---|---|
| Starts to run | Immediately when you create it | Only when you call a run function |
| Error type | any, not visible |
The second type parameter holds it |
| Dependencies | Hidden inside closures | The third type parameter holds them |
| Reusable | No, a Promise resolves 1 time | Yes, you can run the same effect many times |
| Cancel | Not built in | Interrupt is built in |
In this section you learn to create effects, read their types, put them in sequence, and run them. Nothing more.
Learn #
Lesson 1. An Effect is a description, not an action #
The most important idea comes first. When you create an effect, nothing happens. In plain TypeScript this line prints immediately:
const p = new Promise<void>((resolve) => { console.log("hi"); resolve() })
// "hi" is already on the screenThe Effect version below builds a description of "print hi" and puts it in a variable. Nothing prints until you call a run function. Because the effect is only a description, you can run it many times. Each run does the work again.
import { Effect } from "effect"
// This builds a description. Nothing prints yet.
const sayHi = Effect.sync(() => console.log("hi"))
console.log("before running")
// Running the description does the work. Running twice does it twice.
Effect.runSync(sayHi)
Effect.runSync(sayHi)
console.log("after running")
Notice that "before running" prints first, although the code created sayHi before it. Delete both runSync lines: "hi" does not print. The description does not do the work. The run function does the work.
Lesson 2. Reading the type: Effect<A, E, R> #
Every effect has 3 type parameters. If you hover over the variables in a real editor, you see these types:
| Constructor | Type | Meaning |
|---|---|---|
Effect.succeed(42) |
Effect<number, never, never> |
Gives a number, cannot fail, needs no services |
Effect.fail("nope") |
Effect<never, string, never> |
Gives no value, fails with a string, needs no services |
never is the TypeScript type for "this cannot happen". When E is never, the compiler guarantees that the effect cannot fail. When R is never, the effect needs no services.
To see both outcomes without a crash, run the effect with Effect.runSyncExit. This function returns an Exit. An Exit is a plain value. It is a Success with a value, or a Failure with a cause. Later sections explain Exit and Cause in detail. For now, only the tag is important.
import { Effect, Exit } from "effect"
const ok = Effect.succeed(42) // Effect<number, never, never>
const bad = Effect.fail("no permission") // Effect<never, string, never>
const exit1 = Effect.runSyncExit(ok)
const exit2 = Effect.runSyncExit(bad)
console.log(exit1._tag, Exit.isSuccess(exit1) ? exit1.value : "")
console.log(exit2._tag, Exit.isFailure(exit2) ? "it failed" : "")
Run bad with Effect.runSync in place of runSyncExit. The process throws, because runSync expects success. runSyncExit never throws.
Lesson 3. Sequencing steps with Effect.gen #
Most real programs have several steps in sequence. Plain TypeScript uses async/await:
async function main() {
const user = await loadUser("1")
const posts = await loadPosts(user.id)
return posts.length
}Effect uses a generator function with yield* in the same positions. Effect.gen takes a generator and gives one effect. This effect runs the steps in order. Where you write await in plain TypeScript, you write yield*. Where you write return, you still write return. The returned value becomes the success value.
The whole generator is still only a description. Nothing inside it runs until you run the outer effect.
import { Effect } from "effect"
// Two tiny "services" that just return data
const loadUser = (id: string) => Effect.succeed({ id, name: "Ada" })
const loadPosts = (userId: string) => Effect.succeed(["post-1", "post-2", "post-3"])
// Same shape as async/await, with yield* instead of await
const program = Effect.gen(function* () {
const user = yield* loadUser("1")
const posts = yield* loadPosts(user.id)
console.log(user.name, "has", posts.length, "posts")
return posts.length
})
const count = Effect.runSync(program)
console.log("returned:", count)
The return inside the generator became the value that runSync gave back. Remove one yield*: TypeScript reports an error, because the variable now holds an effect and not a user.
Lesson 4. Transforming with pipe: map, tap, andThen #
Effect.gen is for step-by-step logic. For small transformations, Effect also gives a pipeline style. This style is similar to array methods, but for effects.
| Function | What it does | Plain TS equivalent |
|---|---|---|
Effect.map(f) |
Changes the success value | promise.then((x) => f(x)) with a plain function |
Effect.tap(f) |
Runs a side effect and keeps the value | A log call inside .then that returns x again |
Effect.andThen(f) |
Continues with the next effect | promise.then((x) => otherPromise(x)) |
Read a pipe from top to bottom. The value on the left goes into each function in turn. Each step returns a new effect. Thus the pipeline is still a description until you run it.
import { Effect } from "effect"
const getPrice = Effect.succeed(100)
const program = getPrice.pipe(
Effect.map((price) => price * 1.15), // add tax
Effect.tap((total) => Effect.sync(() => console.log("total is", total))),
Effect.andThen((total) => Effect.succeed("charged " + total)) // chain into another Effect
)
console.log(Effect.runSync(program))
tap did not change the value. It only read the value. If you replace tap with map, the pipeline carries undefined forward, because console.log returns nothing. This is the difference between the 2 functions.
Lesson 5. Control flow: forEach, all, and when #
Real programs run one effect for each element of a list, or several effects together. In plain TypeScript you write a loop and Promise.all:
const names = []
for (const id of [1, 2, 3]) names.push(await loadName(id))
const [name, posts] = await Promise.all([loadName(1), countPosts(1)])
if (await isAdmin(1)) await notify("admin Ada")Effect has one function for each of these shapes. Each function takes effects and returns one new effect. The result type follows the shape of the input.
| Function | Input | Result type | Use it when |
|---|---|---|---|
Effect.forEach(items, f) |
A list and a function that returns an effect | Effect<Array<B>> |
You need one result per element |
Effect.forEach(items, f, { discard: true }) |
The same | Effect<void> |
You only need the side effects |
Effect.all([a, b]) |
A tuple of effects | Effect<[A, B]> |
A fixed number of different effects |
Effect.all({ a, b }) |
An object of effects | Effect<{ a: A; b: B }> |
You want the results by name |
Effect.when(effect, condition) |
An effect and an Effect<boolean> |
Effect<Option<A>> |
The condition is itself an effect |
forEach and all run the effects in sequence by default. The Concurrency section shows the concurrency option. when returns an Option: Some with the value when the effect ran, None when it did not run. An Option is the Effect data type for "a value or nothing".
Note: Effect v4 has no Effect.unless and no Effect.if. For a plain boolean, write an if statement inside Effect.gen.
import { Effect, Option } from "effect"
const names: Record<number, string> = { 1: "Ada", 2: "Lin", 3: "Bo" }
const loadName = (id: number) => Effect.succeed(names[id] ?? "?")
const countPosts = (id: number) => Effect.succeed(id * 2)
const notify = (name: string) => Effect.sync(() => console.log("notified", name))
const isAdmin = (id: number) => Effect.succeed(id === 1)
const program = Effect.gen(function* () {
// 1. forEach: one effect per element, in order. The results come back as an array.
const loaded = yield* Effect.forEach([1, 2, 3], loadName) // Array<string>
console.log("names:", loaded.join(", "))
// 2. forEach with discard: only the side effects. The result is void.
yield* Effect.forEach(loaded, notify, { discard: true })
// 3. all over a tuple: the result is a typed tuple.
const [name, posts] = yield* Effect.all([loadName(1), countPosts(1)]) // [string, number]
console.log(name, "has", posts, "posts")
// 4. all over an object: the result has the same keys as the input.
const data = yield* Effect.all({ name: loadName(2), posts: countPosts(2) }) // { name: string; posts: number }
console.log(data.name, "has", data.posts, "posts")
// 5. when: the effect runs only if the condition effect gives true. The result is an Option.
const sent = yield* Effect.when(notify("admin Ada"), isAdmin(1))
const skipped = yield* Effect.when(notify("admin Lin"), isAdmin(2))
console.log("sent:", Option.isSome(sent), "skipped:", Option.isNone(skipped))
})
Effect.runSync(program)
Change data.posts to data.post. TypeScript reports an error, because the result of Effect.all has only the keys of the input object. Remove { discard: true } from step 2. The output is the same, but the yield* now gives an array of 3 undefined values.
Lesson 6. Loops and recursion: whileLoop and suspend #
Some work does not have a list to loop over. A countdown runs until a number is 0. A poll runs until a job is complete. There are 3 ways to write this with effects:
- A plain
fororwhileloop insideEffect.gen. Each step hasyield*. Effect.whileLoop({ while, body, step }).whileis a function that returns a boolean.bodyis a function that returns the effect for one step.stepreceives the result of each step.- A function that returns an effect and calls itself.
The third way has a trap. Look at the function eagerSumTo in the code. When you call eagerSumTo(5), the function calls eagerSumTo(4) at once, and so on to 0. Thus the call builds all 6 effects before any run. For a large number, the call uses too much stack space and throws a RangeError.
Effect.suspend(() => effect) is the fix. It takes a function and delays the call until the effect runs. Thus lazySumTo(5) builds nothing. The runtime calls the function one step at a time, and the stack stays small.
Note: Effect v4 has no Effect.loop and no Effect.iterate. Use the 3 ways above.
import { Effect } from "effect"
// 1. A plain loop inside Effect.gen. yield* runs each step.
const countdown = Effect.gen(function* () {
for (let n = 3; n > 0; n--) {
yield* Effect.sync(() => console.log("t-minus", n))
}
console.log("liftoff")
})
// 2. whileLoop: while and body are functions, so both run at run time, not before.
let remaining = 3
const drain = Effect.whileLoop({
while: () => remaining > 0,
body: () => Effect.sync(() => remaining--),
step: (left) => console.log("drained, before:", left)
})
// 3. Recursion without suspend. The call eagerSumTo(5) builds the whole chain before any run.
let built = 0
const eagerSumTo = (n: number): Effect.Effect<number> => {
built++
return n === 0 ? Effect.succeed(0) : Effect.map(eagerSumTo(n - 1), (rest) => rest + n)
}
const eager = eagerSumTo(5)
console.log("eager: built", built, "effects before run")
// 4. suspend delays the body until the effect runs. The call lazySumTo(5) builds nothing.
built = 0
const lazySumTo = (n: number): Effect.Effect<number> =>
Effect.suspend(() => {
built++
return n === 0 ? Effect.succeed(0) : Effect.map(lazySumTo(n - 1), (rest) => rest + n)
})
const lazy = lazySumTo(5)
console.log("lazy: built", built, "effects before run")
Effect.runSync(countdown)
Effect.runSync(drain)
console.log("eager sum:", Effect.runSync(eager))
console.log("lazy sum:", Effect.runSync(lazy), "- built", built, "during the run")
Change eagerSumTo(5) to eagerSumTo(200000). The call throws a RangeError before any run, because each nested call needs stack space. Change lazySumTo(5) to lazySumTo(200000) in place of this. The program prints the sum, because the runtime builds one step at a time. Caution: a function that returns an effect and calls itself must make the call inside Effect.suspend or inside Effect.gen.
Lesson 7. Bringing in code that can throw or is async #
Real code calls JSON.parse, fetch, and other functions that throw or return Promises. Effect gives one constructor for each situation. When you select the correct constructor, the error becomes visible in the type.
| Your code is... | Use | Error type becomes |
|---|---|---|
| Sync, cannot throw | Effect.sync(() => ...) |
never |
| Sync, can throw | Effect.try({ try, catch }) |
The return type of catch |
| Async, cannot reject | Effect.promise(() => ...) |
never |
| Async, can reject | Effect.tryPromise({ try, catch }) |
The return type of catch |
The catch function receives the thrown value as unknown. You change this value into a typed value. In this example, both errors become plain strings. In the Error Management section, you use tagged error classes in place of strings.
import { Effect } from "effect"
// A sync operation that can throw, made safe and typed
const parseJson = (raw: string) =>
Effect.try({
try: () => JSON.parse(raw) as { port: number },
catch: () => "invalid json" // Effect<{ port: number }, string>
})
// A fake async API that rejects for unknown ids
const fakeFetch = (id: number): Promise<string> =>
id === 1 ? Promise.resolve("Ada") : Promise.reject(new Error("404"))
const fetchName = (id: number) =>
Effect.tryPromise({
try: () => fakeFetch(id),
catch: () => "user " + id + " not found" // Effect<string, string>
})
const program = Effect.gen(function* () {
const config = yield* parseJson('{"port": 8080}')
const name = yield* fetchName(1)
console.log("port", config.port, "user", name)
})
Effect.runPromise(program)
Change fetchName(1) to fetchName(2). The program fails with the string "user 2 not found", and runPromise rejects. This is not an unexpected crash. The type described this failure from the start.
Lesson 8. Running: runSync, runPromise, and the Exit variants #
An effect runs only at the edge of your program, when you give it to a runner. Select the runner with 2 questions. Is the work synchronous? Do you want a failure to throw?
| Runner | Async permitted? | On failure |
|---|---|---|
Effect.runSync |
No, it throws when it finds async work | Throws |
Effect.runSyncExit |
No | Returns Exit.Failure |
Effect.runPromise |
Yes | Rejects the Promise |
Effect.runPromiseExit |
Yes | Resolves with Exit.Failure |
Keep one run call at the top of your application. All code below this call stays a description, and you can combine it with other descriptions. Note: many small runSync calls in the code are a sign that the code does not use effects as values.
import { Effect, Exit } from "effect"
const syncWork = Effect.succeed("sync result")
const asyncWork = Effect.promise(() => new Promise<string>((resolve) => setTimeout(() => resolve("async result"), 10)))
const failing = Effect.fail("boom")
const main = async () => {
console.log(Effect.runSync(syncWork))
console.log(await Effect.runPromise(asyncWork))
const exit = await Effect.runPromiseExit(failing)
console.log(Exit.isFailure(exit) ? "handled failure without throwing" : "unexpected")
try {
Effect.runSync(asyncWork) // async inside runSync is not allowed
} catch (e) {
console.log("runSync refused async work")
}
}
main()
The last case is a common mistake on the first day. If an effect contains an async step, runSync throws. If you are not sure, use runPromise.
Do and don't #
| Do | Don't | Why |
|---|---|---|
| Give every effect to one runner at the top of the application. | Do not call Effect.runSync inside functions that build effects. | An effect that runs early is no longer a value, so you cannot combine it, retry it, or test it. |
Use Effect.try for synchronous code that can throw. | Do not put code that can throw inside Effect.sync. | A throw inside Effect.sync becomes a defect with no type, and Effect.catch cannot see it. |
Use Effect.tryPromise for a Promise that can reject. | Do not wrap a Promise that can reject in Effect.promise. | Effect.promise declares that the Promise cannot reject, so a rejection becomes a defect and E stays never. |
Write yield* in front of every effect inside Effect.gen. | Do not assign an effect to a variable and then use it as a plain value. | Without yield*, the variable holds the effect itself, and the code does not compile or prints an object. |
Use Effect.andThen (or Effect.flatMap) when the function returns an effect. | Do not use Effect.map with a function that returns an effect. | Effect.map wraps the return value, so you get an effect inside an effect and the next step sees an object. |
Use Effect.runPromise when the effect contains async work. | Do not use Effect.runSync on an effect that waits for a Promise or a timer. | Effect.runSync must complete before it returns, so it throws when it finds async work. |
Declare the error type in the return annotation, for example Effect.Effect<number, string>. | Do not annotate a function as Effect.Effect<number> when its body calls Effect.fail. | Effect.Effect<number> means E is never, so the compiler rejects the Effect.fail in the body. |
Put the body of a function that returns an effect and calls itself inside Effect.suspend. | Do not let such a function call itself directly in a map or andThen argument. | The first call builds the whole chain before any run, and a deep chain throws a RangeError. |
Fix it #
Each program below is broken or incomplete. Make it print the expected output with zero type errors. Use hints before the solution.
1. The forgotten yield* #
This program must print 2, but it does not compile. Correct it. Do not change the console.log line.
import { Effect } from "effect"
const program = Effect.gen(function* () {
const n = Effect.succeed(1)
console.log(n + 1)
})
Effect.runSync(program)
2. Nothing happens #
The program is correct, but it prints nothing. Make it print hello from effect.
import { Effect } from "effect"
const program = Effect.sync(() => console.log("hello from effect"))
program
3. The loop that runs nothing #
The program must greet both names before it prints done, but only done prints. Replace the for loop with one Effect function that runs greet for each name. Do not change greet.
import { Effect } from "effect"
const greet = (name: string) => Effect.sync(() => console.log("hello", name))
const program = Effect.gen(function* () {
for (const name of ["Ada", "Lin"]) {
greet(name)
}
console.log("done")
})
Effect.runSync(program)
4. The key that does not exist #
The program does not compile. The code reads a property that the result of Effect.all does not have. Correct the property name so that the program prints Ada has 2 posts. Do not change the object that goes into Effect.all.
import { Effect } from "effect"
const loadName = (id: number) => Effect.succeed(id === 1 ? "Ada" : "Lin")
const countPosts = (id: number) => Effect.succeed(id * 2)
const program = Effect.gen(function* () {
const data = yield* Effect.all({ name: loadName(1), posts: countPosts(1) })
console.log(data.name, "has", data.postCount, "posts")
})
Effect.runSync(program)
5. A throw in the wrong constructor #
JSON.parse throws on bad input. At the moment, Effect treats this throw as a bug (a defect), and the raw SyntaxError comes out. Change only the constructor for the parse step. The failure must become the typed error "invalid json", and the program must print error: invalid json.
import { Cause, Effect, Exit } from "effect"
const parse = (raw: string) =>
Effect.sync(() => JSON.parse(raw) as { name: string })
const exit = Effect.runSyncExit(parse("{ not json"))
if (Exit.isFailure(exit)) {
console.log("error:", Cause.squash(exit.cause))
} else {
console.log("parsed", exit.value.name)
}
6. Wrong runner #
The program contains async work, and it crashes when it runs. Change the runner so that the program prints done after 5ms.
import { Effect } from "effect"
const wait = Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 5)))
const program = Effect.gen(function* () {
yield* wait
console.log("done after 5ms")
})
Effect.runSync(program)
7. map or andThen? #
The pipeline must double the number and print 20, but it prints an unexpected value. Correct the pipeline so that the doubled value is a plain number.
import { Effect } from "effect"
const double = (n: number) => Effect.succeed(n * 2)
const program = Effect.succeed(10).pipe(
Effect.map((n) => double(n)),
Effect.map((result) => console.log(String(result)))
)
Effect.runSync(program)
8. The type annotation that lies #
parseAge has a return type that says it cannot fail, but its body can fail. Correct the return type annotation so that the code compiles and prints the 2 lines below. Do not remove the Effect.fail.
import { Effect, Exit } from "effect"
const parseAge = (input: string): Effect.Effect<number> => {
const n = Number(input)
return Number.isNaN(n) ? Effect.fail("not a number: " + input) : Effect.succeed(n)
}
for (const input of ["30", "abc"]) {
const exit = Effect.runSyncExit(parseAge(input))
console.log(Exit.isSuccess(exit) ? "age " + exit.value : "rejected " + input)
}
Build it #
Write the program from the spec. The output must match exactly.
1. Safe division #
Write divide(a, b) that returns an effect. The effect fails with the string "division by zero" when b is 0. Otherwise it succeeds with a / b. Then loop over the pairs [10, 2], [7, 0], [9, 3]. Run each pair with Effect.runSyncExit and print one line per pair in this exact format:
10 / 2 = 5
7 / 0 failed: division by zero
9 / 3 = 3To read the error out of a failed Exit, use Cause.squash(exit.cause). This function returns the failure value.
import { Cause, Effect, Exit } from "effect"
// TODO: divide returns Effect<number, string>
const divide = (a: number, b: number) => {
throw new Error("TODO")
}
const pairs: Array<[number, number]> = [[10, 2], [7, 0], [9, 3]]
for (const [a, b] of pairs) {
// TODO: run divide(a, b) with Effect.runSyncExit and print the result
}
2. Config loader pipeline #
You have a raw config string, and you need a validated port number. Build 3 small effects and one Effect.gen that combines them:
parse(raw)usesEffect.tryto parse JSON into{ port: unknown }. It fails with"bad json".toNumber(value)succeeds with the value if the value is anumber. Otherwise it fails with"port is not a number".inRange(port)succeeds if1 <= port <= 65535. Otherwise it fails with"port out of range".
loadPort(raw) combines the 3 steps with Effect.gen and returns the port. Run it on the 3 inputs below with Effect.runSyncExit and print exactly:
ok 8080
error port out of range
error bad jsonInputs, in order: '{"port": 8080}', '{"port": 70000}', 'not json'.
import { Cause, Effect, Exit } from "effect"
// TODO: parse, toNumber, inRange
const loadPort = (raw: string) =>
Effect.gen(function* () {
// TODO: chain the three steps and return the port
return 0
})
const inputs = ['{"port": 8080}', '{"port": 70000}', "not json"]
for (const raw of inputs) {
const exit = Effect.runSyncExit(loadPort(raw))
// TODO: print "ok <port>" or "error <message>"
}
3. Fake API client #
Wrap a Promise-based API in Effect. The API is given: api.getUser(id) resolves with a user for the ids 1 and 2, and rejects for other ids.
Write getUser(id) with Effect.tryPromise. A rejection must become the typed error "user <id> not found". Then write program with Effect.gen. The program gets users 1 and 2 and prints their names on one line as Ada & Lin. Then it tries user 3 and prints the error. Get user 3 with Effect.runPromiseExit and use Cause.squash to get the message. Exact output:
Ada & Lin
user 3 not foundimport { Cause, Effect, Exit } from "effect"
const api = {
getUser: (id: number): Promise<{ id: number; name: string }> => {
const users: Record<number, string> = { 1: "Ada", 2: "Lin" }
return id in users ? Promise.resolve({ id, name: users[id]! }) : Promise.reject(new Error("404"))
}
}
// TODO: getUser(id) with Effect.tryPromise
const program = Effect.gen(function* () {
// TODO: fetch users 1 and 2, print "Ada & Lin"
// TODO: run getUser(3) via Effect.runPromiseExit and print the error message
})
Effect.runPromise(program)
Recall #
Answer in your head first, then reveal. Come back to these tomorrow.
What happens when you create an effect but do not run it? #
Nothing. An effect is a description of work. Only a runner such as runSync or runPromise does the work. A Promise is different: it starts the work when you create it.
What is the type of `Effect.fail(new Error("x"))`? #
Effect<never, Error, never>. It gives no success value, it fails with an Error, and it needs no services.
In `Effect<A, E, R>`, what does `R = never` mean? #
The effect needs no services from outside to run. In the Requirements Management section, R becomes a type other than never.
Which constructor do you use to wrap `fs.readFileSync`, which can throw? #
Effect.try({ try, catch }). The function is synchronous and can throw. Effect.sync is only for code that cannot throw. A throw inside Effect.sync becomes a defect, not a typed failure.
What is the difference between `Effect.map` and `Effect.andThen`? #
map takes a plain function and wraps its return value. andThen (or flatMap) takes a function that returns an effect and removes the nested layer. Thus you do not get an effect inside an effect.
When does `Effect.runSync` throw? #
It throws when the effect fails, or when the effect contains async work such as a Promise or a timer. Use runSyncExit to get a value in place of a throw. Use runPromise when the effect has async work.
Inside `Effect.gen`, what is the equivalent of `await`? #
yield*. It unwraps the success value of an effect. If the effect fails, it stops the generator.
What is the success type of `Effect.all({ user: loadUser(1), posts: loadPosts(1) })`, where `loadUser` gives a `User` and `loadPosts` gives an `Array<Post>`? #
{ user: User; posts: Array<Post> }. Effect.all keeps the keys of the input object. With a tuple input, it gives a tuple. With Effect.forEach over a list, you get an Array of the results.