A gentle introduction to Effect TS
I've recently been writing TypeScript again in a green field project, and we made the decision to use the Effect library. It's not a small decision to make: although you can use bits of Effect in an existing code base, its real benefit is when it is used as (to quote the website) "the missing standard library for TypeScript".
Overall, we're happy with the choice but the learning curve has a few sharp edges and while the docs aren't bad it does feel like they could do a better job of introducing the most used features in a way that feels familiar to TypeScript developers so they can at least read existing Effect code before starting to introduce the many, many, powerful features that come with the library.
This post is an attempt to do exactly that.
The Effect type
The very first thing you need to understand about Effect is that it is built around a specific type.
Appropriately enough, the Effect
type. This type functions as a replacement for Promise
, allowing
for asynchronous code, but also covers a few other needs.
Let's get started, in the traditional way:
import { Effect } from "effect" const hello = (name?: string): Effect.Effect<string> => Effect.gen(function* () { return `Hello, ${name || "world"}!` })
This code is spiritually the same as writing:
const helloAsync = async (name?: string): Promise<string> => { return `Hello, ${name || "world"}!` }
You can think of the Effect.gen(function* () { ... })
as a slightly more verbose version of the async
prefix to a function.
What about calling the function? Again, we have a similar concept but different syntax to async/await
.
// Effect version const sayHello: Effect.Effect<void> = Effect.gen(function* () { const greeting = yield* hello("world") yield* sendGreetings(greeting) }) // Async/await version const sayHelloAsync: Promise<void> = { const greeting = await helloAsync("world") await sendGreetingsAsync(greeting) }
Given that we're using a cooperative scheduling system for asynchronous code, you can think of yield*
as
saying "hey runtime - I'm yielding the thread now, start me back up when the effect on the right has finished."
There is, however one big difference between these two which will catch you out if you don't know about it.
Hot and cold promises
Promise
is what is sometimes referred to as a "hot" or "immediate" asynchronous construct. What does that mean?
Well, in the example above sayHelloAsync
is a constant value. As soon as the value is created, the logic within
the promise will be scheduled for execution and we will start the process of asynchronously running the helloAsync
function. If we await the result of sayHelloAsync
somewhere else, we will block until the function has finished.
This also means that if we await
sayHelloAsync
in two different places, we will only send greetings once. After
all, the Promise
type that we are handing around represents the running execution of an asynchronous process; it has
already started, and awaiting it multiple times won't start it again.
Effect
, by contrast, is a "cold" or "thunked" asynchronous construct. It represents a series of steps that will be
executed if the result is requested.
So if we run:
const camelot: Effect.Effect<void> = Effect.gen(function* () { yield* sayHello yield* sayHello yield* sayHello })
We will send the greetings three times, but only when somebody requests the result of running camelot
. Until
then, nothing will happen at all.
If you remember nothing else from this post, remember that an Effect
that nobody executes never runs. This will catch you
out with logging.
// logs nothing const camelot: Effect.Effect<void> = Effect.gen(function* () { Effect.log("Bass voice: spam a looooooot") yield* sayHello yield* sayHello yield* sayHello }) // logs the bass voice const camelot: Effect.Effect<void> = Effect.gen(function* () { yield* Effect.log("Bass voice: spam a looooooot") yield* sayHello yield* sayHello yield* sayHello })
Although the ability to create Effects without executing them can be extremely useful, leaving yield*
out of
your code when you actually need it is probably the most frustrating element of learning Effect, and the one
that the compiler is least able to warn you about.
Pipes
Effect makes heavy usage of a concept it calls "pipes", which is a way of passing a value through a series of functions.
The main place you'll see this is that we can wrap Effect
values in a wide variety of ways. These are very powerful,
but quickly start suffering from the "lisp effect" of a pyramid of brackets when we start combining them.
Let's rewrite the camelot
function above using the built in Effect.repeatN
method:
const camelot: Effect.Effect<void> = Effect.gen(function* () { yield* Effect.repeatN(2)(sayHello) })
Now let's add a timeout on finishing the 3 calls to sayHello
; greetings are time critical after all:
const camelot: Effect.Effect<void> = Effect.gen(function* () { yield* Effect.timeout("10 seconds")(Effect.repeatN(2)(sayHello)) })
You can see this is already becoming quite hard to follow. This is where pipes come in. We can rewrite the above to become:
const camelot: Effect.Effect<void, TimeoutException> = Effect.gen(function* () { yield* sayHello.pipe( Effect.repeatN(2), Effect.timeout("10 seconds") ) })
You'll see this a lot in most code bases as the Effect library contains "pipeable" methods for everything from error handling to naming pieces of code for telemetry.
Error handling, you say?
In that last example, Effect
suddenly grew its second generic parameter. The timeout
wrapper adds the
explicit possibility that camelot
can fail with a TimeoutException
.
Do not get caught out! This doesn't mean that camelot
cannot throw other exceptions; it means that we
do not consider camelot
timing out to be a "defect" but a known behavior which we can take action on.
This is worth spending a moment on, as it can get confusing. Effect executions can "finish early" in three main ways:
- An exception is thrown. This is referred to as a "defect", and the Effect interrupted is said to "die".
- A specific, known, failure type is explicitly returned. The type will be part of the type signature of the Effect, and if this happens the Effect is said to "fail".
- The Effect runtime can interrupt an Effect that is in flight because the result is no longer needed. These Effects are "interrupted".
For example, if the timeout is triggered in the code above the camelot
function will "fail" with a TimeoutException
while the call to sayHello
which has taken too long will be "interrupted".
If, instead, sayHello
throws an exception because the network is down then it will "die" and then in turn cause the camelot
Effect to "die" as well.
With the addition of the "empty" case (nothing went wrong!) these four categories are used to define the Cause
of an Effect completing execution.
Here we hit a piece of (in my opinion) terrible naming on the part of the library authors. The Effect.catch
wrapper that as a TypeScript developer
you would expect to, you know, catch thrown exceptions only catches "failures" (i.e. the known, type safe, errors deliberately returned). This is very
useful in allowing you to apply logic in code that depends on known failure routes, but if you're actually looking for what you thought Effect.catch
did you're really looking for Effect.catchAllCause
.
Opinion moment
This is my opinion rather than something about how Effect works, but I'd recommend in general that you handle most of the library supplied failure
types very close to where they happen. The reason is that as library failures, they are by nature very generic (ElementNotFoundException
?) and
so if you don't handle them close to the source, you won't know which element of what wasn't found. Even worse, if you decide that you can't
do anything about the failure anymore because the context has been lost and you upgrade it to a "defect" using the Effect.orDie
wrapper,
the stack trace will come from the call to orDie
not from the Effect that returned the failure.
Signaling errors
If you want to signal that a function should return early due to an error, you can either use Effect.fail
or you can
use any error that is a subtype of YieldableError
(part of the Effect library). This introduces the slightly weird
construct of return yield*
, as you can see below.
const canGoWrong = (input: number) => Effect.gen(function* () { if(input < 0) { return yield* Effect.fail("Input must be positive") } if (input > 10) { return yield* new IllegalArgumentException("Input must be not too big") } yield* sendGreeting(" Moderate Number Inputter") return "Success!" })
Why can't you just call return
? The answer is it messes up the types; the function above has the type:
(input: number) => Effect.Effect<string, string | IllegalArgumentException>
But if we were to write:
const canGoWrong = (input: number) => Effect.gen(function* () { if(input < 0) { return Effect.fail("Input must be positive") } if (input > 10) { return new IllegalArgumentException("Input must be not too big") } yield* sendGreeting(" Moderate Number Inputter") })
…then it would end up with the type:
( input: number ): Effect.Effect<string | IllegalArgumentException | Effect.Effect<never, string>>
What's happening is that without the yield*
we are returning the failures as possible success values.
That probably isn't what you want!
In general, if you ever find yourself with an Effect of an Effect, you're probably missing a yield*
somewhere.
Dependencies
One of the most powerful features of Effect in day to day usage is the built in, type safe, dependency management. Let's apply some inversion of control
to our sendGreetings
service.
Effect allows us to build "services", which are classes that extend Context.Tag
to specify what interface their implementations will provide.
For example, we can specify a service for sending greetings that looks like this:
import { Effect, Context } from "effect" interface ISendGreetings { sendGreetings(name: string): Effect.Effect<void> } export class SendGreetings extends Context.Tag("SendGreetings")<SendGreetings, ISendGreetings>() {}
And now we can rewrite camelot
to use the service:
const sayHello: Effect.Effect<void, never, SendGreetings> = Effect.gen(function* () { const sender = yield* SendGreetings const greeting = yield* hello("world") yield* sender.sendGreetings(greeting) }) const camelot: Effect.Effect<void, TimeoutException, SendGreetings> = Effect.gen(function* () { yield* sayHello.pipe(Effect.repeatN(2), Effect.timeout("10 seconds")) })
This is where we see Effect's third and final generic parameter, which tracks which dependencies your code requires.
"But I thought we could only yield* Effects?": well, it turns out this isn't quite true. It turns
out that as you can make your own Awaitable
types in TypeScript that are not Promises
, you
can also implement alternative Yieldable
types in Effect. And that's exactly what the pre-provided
Context.Tag
class does as a static interface. Which means, slightly bizarrely, you can yield*
the name of the class and it will then run all of the dependency injection logic needed to go
and get you an actual implementation at run time.
In general, you just want to let Effect build up required dependencies itself; if we add a second service then the type system will capture that "automatically". If possible, leave the types of your Effect functions inferred as then it will automatically pick up changes in their dependencies. That said, I'll carry on adding them here so you can see what's happening.
const sayHello: Effect.Effect<void, never, SendGreetings | TranslateGreeting> = Effect.gen( function* () { const sender = yield* SendGreetings const translator = yield* TranslateGreeting const greeting = yield* hello("world") const translated = yield* translator.translate(greeting) yield* sender.sendGreetings(translated) } )
Note that the types only capture direct dependencies. Here, the TranslateGreeting
service almost
certainly depends in turn on some kind of user or session service, but we don't need to worry about
that. This makes providing alternative service implementations for tests, or for running on the client
versus the server, exceedingly straight forward and safe. If you can provide exactly what your code needs
you're good, and it won't ask you for any more than that. If your Effect code is starting to build up
a huge list of dependencies, that's normally a good indicator that you want to start wrapping it in
an interface of its own - that way, the things that call your code don't inherit a huge dependency tree,
and they in turn become more maintainable.
It is the responsibility of the entry point to the runtime to make sure that all services are
provided, which is done via the Layer
type which provides facilities to manage service implementations
with caching and life cycle management. But that, unfortunately, is definitely the subject of a complete
write up of its own.
Have thoughts?
Leave your thoughts and comments on the Mastodon announcement post, and I'll engage with them there.