HOME

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.