We solved part of the problem. Error handling in practice is 2 steps:
- Collecting possible errors
- Handling errors
We did the "collection" part with tryPromise
, but we still miss the "handling" part.
In fact, right now running main
will still crash the app if something goes wrong:
import { Effect } from "effect";
const fetchRequest = Effect.tryPromise(() =>
fetch("https://pokeapi.co/api/v2/pokemon/garchomp/")
);
const jsonResponse = (response: Response) =>
Effect.tryPromise(() => response.json());
const main = Effect.flatMap(fetchRequest, jsonResponse);
/// 👇 This will throw when something goes wrong (`UnknownException`)
Effect.runPromise(main);
> effect-getting-started-course@1.0.0 dev
> tsx src/index.ts
node:internal/process/promises:289
triggerUncaughtException(err, true /* fromPromise */);
^
UnknownException: An unknown error occurred
at e
Before running the effect we write some code to define what happens if Effect
contains UnknownException
.
This operation is called Recovering from an error.
Composing effects with pipe
We will work a lot with functions. Wrapping functions inside each other becomes quickly unreadable.
Let's imagine we add another step to our program:
const fetchRequest = Effect.tryPromise(() =>
fetch("https://pokeapi.co/api/v2/pokemon/garchomp/")
);
const jsonResponse = (response: Response) =>
Effect.tryPromise(() => response.json());
const savePokemon = (pokemon: unknown) =>
Effect.tryPromise(() =>
fetch("/api/pokemon", { body: JSON.stringify(pokemon) })
);
How do we compose this third step? Another flatMap
looks like this:
const main = Effect.flatMap(
Effect.flatMap(fetchRequest, jsonResponse),
savePokemon
);
Where does the program start? In what sequence are these operations executed? Again, unreadable and unmaintainable.
Fear not! There is a better-looking solution: pipe
.
import { Effect, pipe } from "effect";
const main = pipe(
fetchRequest,
Effect.flatMap(jsonResponse),
Effect.flatMap(savePokemon)
);
pipe
takes the result of a function and provides ("pipes") it to the next one in the chain.const main: number = pipe( 10, (num) => num.toString(), // 👈 `num` is 10 (str) => str.length > 0, // 👈 `str` is `num.toString()` (bool) => Number(bool) // 👈 `bool` is `str.length > 0` );
You can now read the program as a series of steps executed top-to-bottom.
Every parameter inside pipe
is a function that provides the result of the previous function, like this:
const main = pipe(
fetchRequest,
(fetchRequestEffect) => Effect.flatMap(fetchRequestEffect, jsonResponse),
(jsonResponseEffect) => Effect.flatMap(jsonResponseEffect, savePokemon)
);
Since this pattern is used everywhere, every Effect
comes with its own pipe
function. We can therefore improve the code even more (without importing pipe
):
import { Effect } from "effect";
const main = fetchRequest.pipe(
Effect.flatMap(jsonResponse),
Effect.flatMap(savePokemon)
);
Notice how this is similar to then
with Promise
:
const main = fetchRequest.then(
(response) => jsonResponse(response).then(
(json) => savePokemon(json)
)
);
Effect dual API
In the previous pipe
code we went from this:
const main = fetchRequest.pipe(
(fetchRequestEffect) => Effect.flatMap(fetchRequestEffect, jsonResponse)
);
To this:
const main = fetchRequest.pipe(
Effect.flatMap(jsonResponse)
);
What is happening here?
In effect most APIs are dual. This means that they accept both multiple or single parameters:
- Pass multiple parameters in the same
flatMap
const main = pipe(
fetchRequest,
(fetchRequestEffect) => Effect.flatMap(fetchRequestEffect, jsonResponse)
);
- Pass single parameters one by one
const main = pipe(
fetchRequest,
// 👉 Notice how `fetchRequestEffect` and `jsonResponse` are inverted compared to before
(fetchRequestEffect) => Effect.flatMap(jsonResponse)(fetchRequestEffect)
);
This is a (simplified) definition of flatMap
:
export declare const flatMap: {
// 👇 First the function (`jsonResponse`) and then `Effect`
<A, B>(f: (a: A) => Effect<B>): (self: Effect<A>) => Effect<B>
// 👇 Both `Effect` and function (`jsonResponse`) in the same `flatMap`
<A, B>(self: Effect<A>, f: (a: A) => Effect<B>): Effect<B>
}
Passing a single parameter at the time is a technique called Partial Application.
In effect functions can be "data-first" or "data-last".
It's used to more easily compose functions:
const dataFirst = (n: number, str: string) => n + str.length; const dataLast = (str: string) => (n: number) => n + str.length; [1, 2, 3, 4].map((value) => dataFirst(value, "abc")); /// 👇 Directly pass the function, no need of intermediate `value` [1, 2, 3, 4].map(dataLast("abc"));
This allows to simplify the code to a series of readable steps:
const main = fetchRequest.pipe(
(fetchRequestEffect) => Effect.flatMap(jsonResponse)(fetchRequestEffect)
);
const main = fetchRequest.pipe(
// 👇 Function composition
Effect.flatMap(jsonResponse)
);
Wrapping values in Effect: succeed
When we wrap any API inside an Effect
the goal is to compose other Effect
and only at the end leave the "Effect world" and run the program.
Effect therefore offers some functions to wrap values inside Effect
. Effect.succeed
does just that:
const num: number = 10;
const numEffect: Effect<number> = Effect.succeed(10);