Effect
The Effect
data type was designed to be a completely type-safe representation of any program.
Conceptually, we can begin to build a mental model of the Effect
data type by thinking of it as a function that takes some Context
as input and when executed can either fail with some Error
or succeed with some Value
.
ts
declare const Effect: (context: Context) => Either<Error, Value>
ts
declare const Effect: (context: Context) => Either<Error, Value>
Note: this mental model should be used for conceptual purposes only -
the Effect
type is much more powerful than just a function.
The Effect
type is parameterized by three generic type parameters:
Effect<Context, Failure, Success>
which have the following meaning:
-
Context
: Represents the contextual data which is required by theEffect
in order to be executed -
Failure
: Represents expected failures that can occur when anEffect
is executed -
Success
: Represents the type of the value that anEffect
can succeed with when executed
Throughout the Effect ecosystem, you will often see Effect
's type parameters
abbreviated as R
, E
, and A
respectively. This is just shorthand for
R
equirements, E
rror, and the success value of type A
.
You can think of each of these type parameters as separate "channels" within the Effect
data type. Your program can interact with each of these "channels" during its execution.
Creating Effects
Effect provides a variety of constructors which can be used to represent programs that succeed and return some value.
In the sections below, we walk through some of the more common use cases.
From a Value
To create an Effect
from a value, you can use the succeed
constructor:
ts
import * asEffect from "@effect/io/Effect"export constfromValue =Effect .succeed ("Hello, World!")
ts
import * asEffect from "@effect/io/Effect"export constfromValue =Effect .succeed ("Hello, World!")
By inspecting the type of fromValue
, we can see it is Effect<never, never, string>
. This type can be interpreted as:
An effect that does not require any context, does not produce any expected failures, and succeeds with a value of type
string
From a Thunk
Generally, to defer a synchronous computation in JavaScript we can use a "thunk".
A "thunk" is a function that takes no arguments and may return some value.
Thunks are primarily useful for delaying the computation of a value until the result of said computation is actually needed.
Sometimes you may want to construct an Effect
from a "thunk". This can be particularly useful when dealing with:
- Synchronous computations that have side-effects (i.e. logging something to the console)
- Expensive computations that are expensive to evaluate
To create an Effect
from a "thunk", we can use the sync
constructor:
ts
import * asEffect from "@effect/io/Effect"export constlogHelloWorld =Effect .sync (() => {console .log ("Hello, World!")return 42})
ts
import * asEffect from "@effect/io/Effect"export constlogHelloWorld =Effect .sync (() => {console .log ("Hello, World!")return 42})
In the above example, we use Effect.sync
to defer our side-effecting call to the console
object until our program is actually executed.
We can also observe that the value that will be returned when this program is executed is of type number
since we return the value 42
from our "thunk".
From a Promise
When dealing with asynchronous side effects that return a Promise
you can use the promise
function.
ts
import * asEffect from "@effect/io/Effect"export constfetchFirstTodo =Effect .promise (() =>fetch ("https://jsonplaceholder.typicode.com/todos/1"))
ts
import * asEffect from "@effect/io/Effect"export constfetchFirstTodo =Effect .promise (() =>fetch ("https://jsonplaceholder.typicode.com/todos/1"))
Once again inspecting the return type we can see that it is Effect<never, never, Response>
.
From a Callback
Sometimes you have to deal with APIs that do not support async/await
or Promise
, but are instead implemented in the "old-school" callback style. To support callback-based APIs, Effect provides the async
constructor.
Note: Although callback-based APIs may be considered "old-school", they are
inherently more powerful than Promise
-based APIs. This is because they are
both faster than Promise
-based APIs as well as more precise in handling
failures.
As an example, we can demonstrate wrapping readFile
from the NodeJS fs
module with Effect:
ts
import * asEffect from "@effect/io/Effect"import * asNodeFS from "node:fs"export constreadTodos =Effect .async <never,NodeJS .ErrnoException ,Buffer >((resume ) => {NodeFS .readFile ("todos.txt", (error ,data ) => {if (error ) {resume (Effect .fail (error ))} else {resume (Effect .succeed (data ))}})})
ts
import * asEffect from "@effect/io/Effect"import * asNodeFS from "node:fs"export constreadTodos =Effect .async <never,NodeJS .ErrnoException ,Buffer >((resume ) => {NodeFS .readFile ("todos.txt", (error ,data ) => {if (error ) {resume (Effect .fail (error ))} else {resume (Effect .succeed (data ))}})})
Something to note in the above example is that we were actually forced to manually annotate the types when calling Effect.async
. Unfortunately, TypeScript is not capable of inferring the type parameters for a callback based on what is returned inside the body of the callback. This limitation of TypeScript is the one minor drawback of using callback-based APIs. Annotating the types of Effect.async
will ensure that the values provided to resume
match the expected types.
Another thing to note is that we probably don't want to have to call the Effect.async
constructor everywhere that we use callback-based APIs. Therefore, it is a very common pattern to define helpers which wrap external APIs into an Effect-based API.
For example, we can refactor the previous code into:
ts
import * asEffect from "@effect/io/Effect"import * asNodeFS from "node:fs"export constreadFile = (path : string):Effect .Effect <never,NodeJS .ErrnoException ,Buffer > =>Effect .async ((resume ) => {NodeFS .readFile (path , (error ,data ) => {if (error ) {resume (Effect .fail (error ))} else {resume (Effect .succeed (data ))}})})export constreadTodos =readFile ("todos.txt")
ts
import * asEffect from "@effect/io/Effect"import * asNodeFS from "node:fs"export constreadFile = (path : string):Effect .Effect <never,NodeJS .ErrnoException ,Buffer > =>Effect .async ((resume ) => {NodeFS .readFile (path , (error ,data ) => {if (error ) {resume (Effect .fail (error ))} else {resume (Effect .succeed (data ))}})})export constreadTodos =readFile ("todos.txt")
What makes all of the above particularly powerful is that we can seamlessly mix synchronous and asynchronous code. When working within the Effect world, everything becomes an Effect.