Skip to content

antoine-coulon/effect-introduction

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Effect, next generation TypeScript

Important note: This is still in the making but as this was requested, I'm open-sourcing the first version.

A practical introduction to the whys of Effect

This introduction comes from Effect workshops I gave in which the main objective was to explain in few hours the whys of Effect coming from raw JavaScript/TypeScript.

This introduction is not about how to write Effect code but rather focuses on why Effect might be an interesting pick for writing softwares using TypeScript as of today taking into account all the common problems we face as developers. As a rule of thumb, each developer should be aware of the problems a tool is solving before even trying to take a look at the implementation details. Hopefully with that short introduction you will first become aware of the existing problems and then understand how elegantly and efficiently Effect solves them.

Effect is a well-rounded tool solving a lot of well-known software engineering problems. Let's first talk about problems before solutions.

Inspiration

This is highly inspired by both excellent talks from Michael Arnaldi (@mikearnaldi) at the WorkerConf and Mattia Manzati at React Alicante.

N.B: If you're already comfortable with the problems Effect tries to solve and wish to jump straight into the hows of Effect, I suggest you to take a look at the official Effect documentation (still in the works) and the excellent crashcourse from Stefano Pigozzi (@pigoz).

Samples and source code

In the src/ folder you will be able to find some samples used alongside the introduction. There is also a TypeScript version if you prefer to both read the content and have the ready-to-be-run and type-checking samples.

Note: TypeScript files are still in the making so they might be incomplete/outdated as of now.

Outcomes you can expect from the introduction

  • Understanding most commons problems we're facing as developers
  • Understanding limits we're facing as JavaScript/TypeScript developers
  • Basic understanding of Effect
  • Basic understanding of an "Effect System"

Before diving into Effect, let's take a step back talking about what problems we commonly face as developers. Effect is a tool in the same way as TypeScript is a tool. Our responsibility is to first understand the problems as it would help us finding the good solutions.

What are the most common challenges we are facing when developing softwares?

We'll show examples using TypeScript, but this is not only related to JavaScript/TypeScript concern. It concerns every ecosystem, language, for instance Effect was initially heavily inspired by ZIO, its Scala counterpart, because most of the problems also apply to Scala.

Hopefully, you'll realise that Effect is just a tool that addresses hard problems that we will always face, regardless the underlying ecosystem/language.

Before diving into these problems and the solutions Effect brings, let me just do a pretty quick prelude that will help you understand right away the approach.

0. Prelude

Let's talk a little bit about the Effect datatype in itself with a bit of background history.

Effect is the core datatype of the ecosystem, but what if I tell you that it could have been called Program instead? The reason for that is that Effect tries to model exactly what a program is, that is something that requires an environment to run, that can fail with an error or succeed with a value.

You can see the original conversation started by @mikearnaldi just there in Effect's Discord

Consequently, Effect is a generic datatype with 3 type parameters: A: represents the value that can be produced by the program E: represents the error that can be produced by the program R: represents the environment required to run the program

Resulting in: Effect<A, E, R>.

import type { Effect } from "effect";

type Program<Environment, Error, Success> = Effect.Effect<
  Success
  Error,
  Environment,
>;

Let's just model a simple command-line interface program (with a very high level of abstraction). We can say that our command line program requires a process to run, that will be granted by the OS. It's represented by the first generic type parameter R. Then, our program can fail with an error of type E (Standard Error) or succeed with a value of type A (Standard Output).

type Stdout = any;
type Stderr = any;
type Process = any;

type CommandLineProgram = Program<Stdout, Stderr, Process>;

Having these 3 generic parameters explicitly defined in the type signature of our program allows us to have a very precise understanding of what our program is doing and what it can produce as a result. In addition to explicitness, Effect provides us a very strong type safety guarantee that the constraints of the generic type parameters will be respected by the implementation of the program.

1. Explicitness

Go to source file (01-explicitness.ts)

The ability of making a program self-describing, allowing to have a clear vision and understanding what outcomes the program can produce without having to run it.

Ideally, what we want is:

  • explicit errors
  • explicit dependencies
  • explicit outcomes

Let's see few examples using TypeScript first, then with Effect

Synchronous computations

function multiplyNumber() {
  const generatedNumber = NumberGeneratorLibrary.generateRandomNumber();
  //    ^ number
  return number * 2;
}

Unfortunately when running the code our program crashes: Error at <anonymous> Without taking a look at the implementation of the generateRandomNumber(), we don't even know that this thing might throw an error. The consequence of that is having runtime defect makes the process just die. Think of that in a wider scope of a program, where this can be very hard to properly handle.

export function generateRandomNumber(): number {
    const randomNumber = Math.random();

    if (randomNumber > 0.9) {
      // RIP
      throw new Error();
    }

    return randomNumber;
}

This behavior can be the root cause of many problems including defensive coding, for instance:

function defensiveMultiplyNumber() {
  try {
    const number = NumberGeneratorLibrary.generateRandomNumber();
    return number * 2;
  } catch {
    // Just in case
  }
}

Or we need to deal with runtime errors the hard way:

function blindlyCatch() {
  try {
    const random = Math.random();

    if (random > 0.9) {
      throw new SomeError();
    }

    if (random > 0.8) {
      throw new SomeOtherError();
    }

    return random;
  } catch (exception: unknown) {
    if (isSomeErrorException(exception)) {
      // do something
    } else if (isSomeOtherErrorException(exception)) {
      // do something else
    }
  }
}

The solution that we just found is not ideal and even if there is only thirty lines of code, compromises must already be done because we simply lack of explicitness.

Asynchronous operations

One way to model an async computation with JavaScript is using a Promise whose results is always delivered asynchronously.

function doSomething(): Promise<number> {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3)
    });
  });
}

doSomething().then(
  // Callback will be executed at some point in time (generally as soon as possible)
  () => {

  }
);

However, Promises are both conceptually limited and lacking a lot of important features to deal with common problems that we face.

Drawbacks of a Promise 😥

  • Eagerly executed, hence is impure, referentially-opaque and is running computation (already a value). Consequently, can't be used around for writing functional programs.

You might already know this eager nature of a Promise, but you might not know that it prevents many interesting rules to be applied.

Purity and referential transparency are important concepts in Functional Programming because they allow you to make assumptions about the behavior of your program levaraging mathematical laws (compositions and substitutions of expressions, etc). Moreover, it helps reasoning about the behavior of your program by just looking at the types, which is what we also target with explicitness. By leveraging compilers, in our case TypeScript, we will be constrained to a set of well-behaved types and principles, allowing us to eliminate whole classes of bugs and unexpected behaviors.

A more detailed version of the explanation is available in the 01-explicitness.ts source file

  • Implicit memoization of the result (either success or failure).

As we already said, a Promise is eagerly executed. It means that as soon as you create a Promise, the computation is already running and might have already completed with a value. That value produced by the Promise is implicitly memoized meaning that when the Promise is settled, the internal state of the Promise is frozen and can't be changed anymore, whether the Promise is fulfilled or rejected. Consequently if you want to run the same computation again, you'll need to recreate the Promise from scratch. Altough this is convenient because it allows subscribers to receive the value even when registering for it after the Promise produced its value, this makes the behavior of a Promise non-reusable and does not favor retries and compositions.

  • Has only one generic parameter: Promise<A>. The error is non-generic/non-polymorphic.

Promise has only one generic parameter, which is the type of the value produced. This is not really convenient because it means that the error is not reflected by default in the type of the Promise. This highly restricts the type-level expressiveness and forces us to deal with untyped and unknown failures. We could say that only generic parameter can be used to represent the error using Either/Result representations, but this model has its own limitations when it comes to combining many operations together and when trying to inferthe type of the errors of the whole chain.

  • Can't depend on any contextual information.

A Promise can't explicitely encode the fact of depending on some contextual information. It means that if you want to run a Promise that depends on some input context, dependencies can not be explicitely modeled hence it is impossible to statically constrain the Promise to only be run in a valid context i.e. with all the requirements satisfied.

This is a problem because this means that Promises can implicitely rely on hidden dependencies and does not offer any flexibility when it comes to composition and dependency injection. By nesting Promises, that implicit layer of dependencies will grow and it will be harder to reason about the behavior and the requirements of the program.

A more detailed version of the explanation is available in the 01-explicitness.ts source file

  • No control over concurrency.

Natively, a Promise does not offer any control execution over concurrency so when composing many Promises together, you can't control how many Promises can be spawned and run in parallel (unbounded concurrency). This is a problem because in most cases you will end up either spawning too many Promises and overloading the system or constrain Promises to run sequentially and not taking advantage of the asynchronous nature of the platform.

This is talked in more details in the Concurrency section of the introduction.

  • Not much built-in combinators (then, catch, finally) and static methods (all, allSettled, race, any, resolve, reject).

By default, Promises don't have much combinators to work with nor Promise constructors and are lacking some important features, for instance all and allSettled are unbounded concurrency-wise, race and any are working as expected but are unsafe because "race losers" are not cleanly interrupted hence underlying resources can not be released (it's also the case for Promise.all).

  • No builtin interruption model.

Following what was said just before, Promises unfortunately don't have a built-in interruption model. There was one attempt to introduce cancellation to Promises that was withdrawn for some unclear reasons. My 2 cents is that it was because adding cancellation into Promises would have introduced too many changes, and the initial design constrained the evolution of the builtin features around Promises.

In any case as of now, we are not able to cancel a Promise using the standard API.

  • No builtin retry logic.

Another feature which won't never see the light of day is the built-in retry policies. Given that a Promise already represents a running computation in itself, it can not be easily retried without being constructed again. We can work around these limitations by introducing lazy promises which are nothing but Promises wrapped in functions over which we have the control, but this approach reduce flexibility, composability and introduce quickly some avoidable complexity.

A more detailed version of the explanation is available in the 03-resilience.ts source file


Promises are everywhere and are part of most codebases when dealing with asynchronous programming, so you might wonder what could be a solid alternative to that. Let's jump right into it.

Alternatives 1/2

fp-ts

width:600px height:300px

fp-ts created by Giulio Canti (@gcanti) is the most popular functional programming library in the TypeScript ecosystem and provides developers with popular patterns and reliable abstractions from typed functional languages.

fp-ts introduced primitives that allow to model such things:

Synchronous

  • IO<A>: Represents a lazy and synchronous computation that are not expected to fail, meaning that executing the thunk produces a value A.
type IO<A> = () => A  

Because errors can't explicitely be represented using IO<A>, it means that conventionally the side effect must not throw any unexpected errors.

  • IOEither<E, A>: When it comes to explicitely representing a typed error that can be produced by the execution of a synchronous computation, fp-ts provides us IOEither<E, A>. It represents a synchronous computation that can fail with an error E.

Asynchronous

  • Task<A>: It is essentially the same as IO<A> except that it describes an asynchronous computation that is not expected to fail.
  • TaskEither<E, A>: It is essentially the same as IOEither<E, A> except that it describes an asynchronous computation that is expected to fail with an error E.

Cool, we already found a solution to favor explicitness and model both the success or failure an operation can produce. It's a great step towards a stronger primitives, but still requires us to make a difference between asynchronous or synchronous computations. Why should we care about whether it's async or sync? We don't care! Ideally, we would like to be able to represent a computation that can both fail with an error E or succeed with a value A for all types of computations and always describe it the same way. There are already many difference between how to deal with asynchronous and synchronous error propagation, and there are even many ways to deal with asynchronous error handling (callbacks vs promises), we want to simplify that both at the type-level and at runtime.

Moreover, there is still:

  • no builtin control over concurrency
  • no builtin interruption
  • no builtin retry
  • composing/combining multiple Tasks gets quickly hard to read
  • semantic differences between synchronous and asynchronous operations

Alternatives 2/2

Effect

The new kid in town

width:600px height:300px

/**
 * An Effect is modeled with the datatype Effect<A, E, R>
 * (A) represents the successful outcome a computation can produce
 * (E) represents the failure a computation can produce
 * (R) represents requirements a computation needs in order to be run
 */ 
interface Effect<A, E, R> {}

In the context of explicit outcomes, Effect is a data type that can be used to model everything at the same time:

  • no distinction between synchronous/asynchronous computations, everything is just a computation
  • can be used to model computations that are expected to fail or not fail, the Either datatype is embedded in the Effect data type

But also Effect is:

  • lazy by nature
  • highly composable
  • highly type-safe
  • explicit errors and dependencies management (dependencies and R and discussed right after)
  • builtin concurrency control
  • builtin interruption
  • builtin retry
  • builtin resource management (acquire/release)

The primary goal of an Effect is to act as a representation of a computation or more generally a program whose outcome (error or success) and dependencies are explicitely modeled.

How we can improve that way of handling errors?

Now that everyone is up-to-date with challenges we are facing dealing with synchronous and asynchronous (promise-based) computations, it's time to go back on our dear explicitness and see how Effect solves that.

To improve the way of handling errors, we can improve the way they are described, and finally make them part of the type signature as well as the value. One universal solution that you might already now is Either (Result-like) implemented natively in Rust, Kotlin, Haskell... Can be implemented in TypeScript as well.

interface Either<A, B> {
  readonly left: A;
  readonly right: B;
}

An Either<A, B> at its root has nothing to do with errors, it's simply a datatype that aims to represent a values with two possibilities, either "A" (left) or "B" (right). The Either type is sometimes used to represent a value which is either correct or an error; by convention, the Left constructor is used to hold an error value and the Right constructor is used to hold a correct value. This Either specialization is what most people now know as a Result<E, A>.

interface Result<Error, Success> extends Either<Error, Success> {}

Effect integrates an Either<E, A> under the hood of each computation, making it both easy and explicit to deal with.

type _ = Effect<A, E, R>
//              ^__^ -> Either-like

Do you remember our first raw TypeScript samples? Let's rewrite it with Effect. Let's consider some code:

import { pipe } from "effect/Function";
import { Effect } from "effect";

namespace EffectNumberGeneratorLibrary {
  export function generateRandomNumber(): Effect.Effect<number, Error, never> {
    return pipe(
      Effect.sync(() => Math.random()),
      Effect.flatMap((randomNumber) => {
        if (randomNumber > 0.9) {
          return Effect.fail(new Error());
        }

        return Effect.succeed(randomNumber);
      })
    );
  }
}

If you take a close look at the above generateRandomNumber() signature, you can see that we have the error typed as Error. Consequently if you describe an Effect that should not produce any known failure that is having the error channel typed as never (Effect<never, never, number>) and try to directly consume an effect that has a typed failure (in that case typed Error), it won't compile. It's great, because we are forced by the compiler to be rigorous and to deal with the error.

Let's see that in action, with multiplyNumberWithoutDealingWithError that is not supposed to produce failures.

function multiplyNumberWithoutDealingWithError(): Effect.Effect<
  number,
  never, // E is typed as 'never', meaning that this Effect is not expected to produce failures (in the same way as IO<A> or Task<A>). 
  never
> {
  return EffectNumberGeneratorLibrary.generateRandomNumber();
  // ^ Type 'Effect<number, Error, never>' is not assignable to type 'Effect<number, never, never>'
}

So now that we are aware of the constraint, how do we come from a description of a computation that will eventually produce a failure to a computation that does not produce failures? Dealing with errors in a recoverable fashion is pretty straightforward.

function multiplyNumberWhenDealingWithError(): Effect.Effect<
  number,
  never,
  never
> {
  return pipe(
    EffectNumberGeneratorLibrary.generateRandomNumber(),
    Effect.flatMap((number) => Effect.succeed(number * 2)),
    // Recovering from the error and producing a successful result value instead
    Effect.catchAll(() => Effect.succeed(0))
  );
}

After having described our recovery logic, the error channel is immediately being changed from Error to never meaning that multiplyNumberWhenDealingWithError computation can benefit from the description of a computation that won't produce expected failures. Consequently we can just deal nicely with that outcome by relying on the typings and be confident about the outcome of the computation.

In that case it's still a very simple example, but keep in mind that Effect leverages pretty well inference in a way that it can keep track and compose the error channel of dozens of chains of effects.

Another benefit of having a dedicated error channel is that we can also model multiple failures using tagged unions. In the following example, by just using TypeScript tagged classes, we are able to make Effect infer a union of typed errors.

export class NumberIsTooBigError {
  readonly _tag = "NumberIsTooBigError";
}

export class NumberIsTooSmallError {
  readonly _tag = "NumberIsTooSmallError";
}

namespace EffectNumberGeneratorLibrary {
  export function generateRandomNumber(): Effect<
    number,
    NumberIsTooBigError | NumberIsTooSmallError,
    never
  > {
    return pipe(
      Effect.sync(() => Math.random()),
      Effect.filterOrFail(
        (randomNumber) => randomNumber > 0.9,
        () => new NumberIsTooBigError()
      ),
      Effect.filterOrFail(
        (randomNumber) => randomNumber < 0.2,
        () => new NumberIsTooSmallError()
      )
    );
  }
}

Note: there might be some cases where TypeScript isn't able to unify union types properly, but thankfully using Effect combinators (here: filterOrFail) we are able to bypass these limitations.

I'm not going to dive into this subject there, but you can read more either in the 01-explicitness.ts section or in the following Discord thread.

Now that we have failures represented as a union, it allows us to pattern match and recover from either specific failures or all failures. Depending on that choice, pattern matched failures will be erased from the error channel and other ones will just remain until some recovery logic is defined at some point.

function multiplyNumberWithExhaustivePatternMatching(): Effect<number, never, never> {
  // Note how the error channel becomes "never" now that we exhaustive pattern match                                           
  return pipe(
    EffectNumberGeneratorLibrary.generateRandomNumber(),
    // If there is no failure
    Effect.flatMap((number) => Effect.succeed(number * 2)),
    // If there are failures, pattern match.
    Effect.catchTags({
      NumberIsTooBigError: () => Effect.succeed(0),
      NumberIsTooSmallError: () => Effect.succeed(1),
    })
  );
}

In the above case the pattern matching is exhaustive, but if it's not the case, the members of the union not being covered by the matching will be still reflected in the error channel.

function multiplyNumberWithPartialPatternMatching(): Effect.Effect<
  number,
  NumberIsTooBigError,
  // ^ partial pattern matching does not erase all errors
  never
> {
  return pipe(
    Effect2NumberGeneratorLibrary.generateRandomNumber(),
    Effect.flatMap((number) => Effect.succeed(number * 2)),
    Effect.catchTags({
      NumberIsTooSmallError: () => Effect.succeed(1),
    })
  );
}

Explicit dependencies

Go to source file, section "Explicit dependencies" (01-explicitness.ts)

Effect can embed contextual information in the same way as the Reader data type from fp-ts was describing it.

It makes the dependencies required for the computation to be run also explicit. Let's say we have a very simple use case whose purpose is to register a new user on a given platform. The use case is meant to be agnostic of implementation details, that means that it ignores how the user registration is indeed persisted, whether it is in a database or some other storage service. The only thing the use case is responsible for is to orchestrate correctly all the business requirements. In our very simple example below, it's only registering the user to a given storage (in a real world application it could be dispatching a domain event, and putting both the user registration and the even dispatch in the same transaction, a la Transactional Outbox for instance).

import { Effect } from "effect";
import * as Context from "effect/Context";

interface UserRepository {
  createUser: () => Effect.Effect<CreatedUser, UserAlreadyExistsError, never>;
}

const UserRepository = Context.GenericTag<UserRepository>("UserRepository");

// Use case depending on an abstract User Repository
function registerUser(): Effect.Effect<CreatedUser, UserAlreadyExistsError, UserRepository> {
                                                                            // ^ explicit dependency
  return pipe(
    UserRepository,
    Effect.flatMap((userRepository) => userRepository.createUser()),
    // ... do something more as part of the use case, sending domain events, etc.
  );
}

What it means is that registerUser needs an instance of some service that implements the interface UserRepository. Until the requirements are satisfied, the program won't compile:

const mainProgram = Effect.runPromise(registerUser());
      // ^ Type 'UserRepository' is not assignable to type 'never': ts(2345)

We can't compile the program because we didn't satisfy the dependencies.

How does it work? Theorically speaking, it's simple. The runtime interpreter checks that the Effect we're trying to run has all the dependencies satisfied. Statically at the type-level, we're able to determine that by checking the R type parameter of the Effect. If the R type parameter is never, it means that all dependencies of the Effect are satisfied. Otherwise, it means that some dependencies are missing (the ones still visible in the R type).

const _program = useCases.registerUser();
//    ^ The type here is Effect<CreatedUser, UserAlreadyExistsError, UserRepository>

Because the R still has UserRepository, it means that the dependency needs to be provided in order for the effect to be run.

One benefit of having explicit dependencies is that conceptually the requirements are very clear and dependencies are not hidden/implicit. Dependencies appearing in the R generic type parameter is only refering to interfaces not any real implementations, this has for consequence to let room for the Dependency Inversion Principle to easily spread everywhere in a effortless way.

This example shows the use of one dependency, but it's important to note that Effect is able to deeply infer the dependencies required as a TypeScript Union type, wherever the dependencies come from in the Effect tree:

const effect1: Effect<number, never, DependencyA> = {};

const effect2: Effect<number, never, DependencyB> = {};

const program: Effect<number, never, DependencyA | DependencyB> = Effect.gen(function* ($) {
                      // ^ See how both respective dependencies from "effect1" and "effect2"
                      // now were propagated in the dependencies of our main program, represented as a typed union.
  const result1 = yield* $(effect1);
  const result2 = yield* $(effect2);

  return result1 + result2;
});

In that case, there is no deep Effect nesting but the principle remains the same.

If you're interested in the story behind the representation of the dependencies as a Union Type, here is the section explaining that in the official Effect documentation.

Type-safe dependency injection

Just before, we mentioned the fact that until a required dependency is satisfied, the program won't compile. Effect provides us a type-safe dependency injection mechanism helping us satisfy the dependency graph.

pipe(
  registerUser(),
  // Dependency injection
  Effect.provideService(UserRepository, {
    createUser: () =>
      // We don't care about the implementation, it could be anything, as soon
      // as it implements the interface contract.
      // Here we just satisfy the interface by producing the expected failure
      Effect.fail(new UserAlreadyExistsError("User already exists")),
  }),
  Effect.runPromise
);

Until we provide the service implementation, the program won't compile because all the computation requirements are not satisfied.

Effect.runPromise(something as Effect<void, never, SomethingService>)
// ^ This won't compile, a computation for which all the requirements are not satisfied (SomethingService) can't be executed.

Effect.runPromise(
  pipe(
    something, // now becomes Effect<never, never, void>, because an implementation matching the interface was injected.
    Effect.provideService(SomethingService, {})
  )
);

Service composition

In real-world application scenarios, services would also depend on a set of other services quickly creating a complex dependency graph to satisfy.

  graph TD;
      ServiceA-->ServiceB;
      ServiceA-->ServiceC;
      ServiceC-->ServiceD;
      ServiceC-->ServiceE;
      ServiceC-->ServiceF;

Thankfully to manage dependency injection at scale, Effect embeds Layers which are recipes for creating services in a composable, effectul, resourceful and asynchronous way. Their goal is to overcome standard constructor limitations and offer more powerful and safe service construction primitives.

Layers describe a set of required dependencies (In) and produces a set of composed dependencies (Out). During Layer construction, errors can occur, hence the Layer signature:

export interface Layer<ROut, E, RIn> {}

In the same spirit as for Effects, using Layers for which dependencies are not satisfied will result in compilation errors.

As part of the introduction, this Layer section ends up there. If you want to know more about Layers, here is a list of advanced resources:

2. Testing

Go to source file (02-testing.ts)

Testing is the ability of asserting that a system behaves as expected. As obvious as it may seem, testing can be very tricky if the program is coupled to implementation details and has implicit (hidden dependencies) that we can't control.

Thankfully, Effect is explicit towards dependencies and favors the use of the Dependency Inversion Principle (DIP) by forcing each computation to depend on an abstraction (interface) rather than on an implementation.

Let's come back to our previous section example:

interface UserRepository {
  createUser: () => Effect.Effect<CreatedUser, UserAlreadyExistsError, never>;
}

const UserRepository = Context.Tag<UserRepository>();

function someUseCase(): Effect.Effect<CreatedUser, UserAlreadyExistsError, UserService> {
  return pipe(
    UserRepository,
    // ^ Just a Tag linked to an interface, there is no implementation yet.
    // The use case completely ignores the implementation of the repository,
    // it just relies on its interface.
    Effect.flatMap((userRepository) => userRepository.createUser()),
  );
}

Having that Dependency Inversion Principle applied together with the builtin dependency injection mechanism, we can easily test programs:

class InMemoryUserRepository implements UserRepository {
  createUser() {
    // 
  }
}

it("Should do something", async () => {
  const user = await Effect.runPromise(
    pipe(
      createUser(), 
      Effect.provideService(UserRepository, new InMemoryUserRepository())
    )
  );
  expect(user).toEqual("something");
});

As we can see, testing is very easy with Effect and it was thought from the ground up to allow an effect description to be decoupled from its implementation details.

Note that this is also beneficial for many other use cases other than testing, for instance changing very easily implementations of a service without breaking code depending on the contract.

3. Resilience

Go to source file (03-resilience.ts)

Resilience is the art of designing and implementing software systems that can gracefully and efficiently recover from most types of failures.

We saw that explicitness and type-safety offered by Effect allow us to erase a whole set of bugs and cleanly deal with errors.

If it compiles, it works - slogan in the making. I can confirm that from my Effect experience since one year and a half.

Consequently, Effect is a very powerful datatype, with a deep inference mechanism making Effect programs highly type-safe. It brings the type-safety to a whole new level by using TypeScript in a excellent way.

As we saw from the Explicitness part, Effect forces us to deal with errors case and forces us to describe computations that are both mathematically correct and make sense from a computer science perspective.

But most of the time, we don't only want to catch error, we also want to retry with some custom policy, that mostly depend on the system we are targeting and its own constraints.

Using vanilla TypeScript, we know and saw before how to deal with an error happening, but how can we simply write a retry mechanism for a given computation?

Let's start by writing a dummy use case which is unluckily always failing. Then, we can start writing a little retry function that is able to retry the computation X times, nothing fancy there.

async function businessUseCase() {
  throw new Error();
}

async function retry(fn: () => Promise<void>, times = 1): Promise<void> {
  try {
    await fn();
  } catch {
    if (times === 0) {
      return;
    }
    return retry(fn, times - 1);
  }
}

retry(businessUseCase, 5);

Great, we are able to retry the computation as many times as we want! But what if we want to retry both X times but also on a specific condition?

async function businessUseCase() {
  const random = Math.random();
  if (random > 0.9) {
    throw new Error("error_1");
  }
  throw new Error("error_2");
}

async function retry(
  computation: () => Promise<void>,
  times = 1,
  shouldRetry: (e: unknown) => boolean
): Promise<void> {
  try {
    await computation();
  } catch (error) {
    if (times === 0 || !shouldRetry(error)) {
      return;
    }
    return retry(computation, times - 1, shouldRetry);
  }
}

retry(
  businessUseCase,
  5,
  (error) => error instanceof Error && error.message === "error_2"
)

As we can see, the complexity grows very quickly for simple cases. In most real-world scenarios, we would want to add time delays between retries, bound the retrying with a maximum duration, etc. Writing it all in that fashion would be very tedious and error-prone.

If we want to combine multiple rules, that is adding a specific debounce of exponential backoff, this would become nearly unmaintainable.

Consequently as the retry function gets more specific, we:

  • lose flexibility
  • lose composability
  • lose the ability of having an error specialization
  • increase the complexity

Thankfully, Effect also comes in with a rich set of builtin ways to deal with retry policies, allowing us to combine a lot of different and human readable strategies.

Let's rewrite the code examples with Effect.

import { Effect, Duration, Schedule, pipe } from "effect";

const computationWithFiveRetries = pipe(
  Effect.fail(new Error("Some error")),
  // Number of retries
  Effect.retry({ times: 5 })
);

const computationWithRetryUntil = pipe(
  Effect.sync(() => Math.random()),
  Effect.flatMap((random) =>
    Effect.fail(
      random > 0.5 ? new Error("Forbidden") : new Error("Unauthorized")
    )
  ),
  // Retry until the condition is met
  Effect.retry({ until: (error) => error.message !== "Forbidden" })
);

And even more complex ones, combining multiple policies to create one composed policy that:

  • Always recurs, but will wait a certain amount between repetitions using exponential backoff, up until the point where repetitions are bounded to 1 second
  • Recurs while the time elapsed during the whole policy is less than or equal to 30 seconds
import { Duration, Schedule, pipe } from "effect";

export const retrySchedule = pipe(
  Schedule.exponential(Duration.millis(10), 2.0),
  Schedule.either(Schedule.spaced(Duration.seconds(1))),
  Schedule.compose(Schedule.elapsed),
  Schedule.whileOutput(Duration.lessThanOrEqualTo(Duration.seconds(30)))
);

We are able to describe a complex retry policy in few lines of code, in a very explicit, elegant and composable way.

Note: one more example is available in the associated TypeScript file (src/03-resilience.ts).

Interruption

Still in the context of Resilience, Effect also allows to deal with interruptions thanks to its concurrency model based on Fibers.

I'm not going to expand on what Fibers are in this section because it will be done in the Concurrency section, but very briefly a Fiber is a lightweight concurrency primitive able to run computations in a type-safe, composable and resource-safe way. Fibers are said resource-safe, meaning that they allow us to model safe resource acquisition and release in case of interruptions, avoiding memory leaks and letting room for many graceful shutdown/clean up mechanisms.

Being able to interrupt computations is a very common pattern, let's take for instance Promise.race:

import { setTimeout } from "node:timers/promises";

Promise.race([
  setTimeout(1000),
  setTimeout(10000),
]);

You might use Promise.race as a convenient way to schedule two asynchronous tasks with the objective of cancelling the loser after the winner settled. Conceptually, racing is a great way of achieving that, the problem being that both computations will be settled letting you think that we are fully done with the tasks, but the truth is that the loser will indeed keep running in the background, without being properly released.

One better way of doing that would be to use the AbortController Web API:

import { setTimeout } from "node:timers/promises";

function makeRace() {
  const abortController1 = new AbortController();
  const abortController2 = new AbortController();

  async function cancellableTimeout1() {
    await setTimeout(1000, undefined, { signal: abortController1.signal });
    console.log("Aborting timeout 2");
    abortController2.abort();
  }

  async function cancellableTimeout2() {
    await setTimeout(10000, undefined, { signal: abortController2.signal });
    console.log("Aborting timeout 1");
    abortController1.abort();
  }

  return Promise.race([cancellableTimeout1(), cancellableTimeout2()]);
}

In that case, the Abort Controller API provide us a way to ask some asynchronous computation to abort its current execution. One drawback of this approach is that it can be very tedious to implement correctly and the control flow becomes very quickly error-prone. Implementing support for the API can even become leaky itself, we'll see that just after our new example.

Let's take a new example of a simple job running in the background, using a setInterval processing literally nothing.

For that we are going to use API that everyone is most likely used to (setInterval), that acquires a resource (a timer) and needs to release it at some point (clearInterval) when we finished processing the job after 10 seconds.

import { setTimeout } from "node:timers/promises";

async function backgroundJob() {
  const processTime = 10_000;
  const interval = setInterval(() => {
    console.log("process something...");
  }, 500);

  try { 
    await setTimeout(processTime);
  } finally {
    clearInterval(interval);
  }
}

Internally, we are able to use the classic try/finally control flow allowing to model the use and release actions. Let's see how we are able to cancel the job in itself from the outside world, that is being able to cancel at any point in time the setInterval.

It becomes a bit more tricky when trying to implement support for the Abort Controller API as we must involve it everywhere in the control flow, it can become tedious and error-prone.

import { setTimeout } from "node:timers/promises";

async function backgroundJobWithCancellation(signal: AbortSignal) {
  const processTime = 10_000;

  if (signal.aborted) {
    return;
  }

  let interval: NodeJS.Timer;

  signal.addEventListener("abort", () => {
    console.log("aborting job, releasing timer resource...");
    clearInterval(interval);
  }, {
    once: true
  });

  interval = setInterval(() => {
    // process something...
  }, 1000);

  try {
    // inherits from the same signal to cancel this timer as well
    await setTimeout(processTime, undefined, { signal });
  } finally {
    clearInterval(interval);
  }
}

You might have noticed it, but if we stop there, we're still leaking memory. In most scenarios, either when receiving a cancellation request through the signal or when the processTime requests the end of task, the setInterval timer is correctly released. However in the later case, the task just ends without using the provided signal, meaning that the listener callback (on "abort" event) will never be called. In that specific case, we are wastefully keeping in memory that listener. Thankfully, addEventListener also natively supports the Abort Controller API.

import { setTimeout } from "node:timers/promises";

async function backgroundJobWithCancellation(signal: AbortSignal) {
  const processTime = 10_000;

  if (signal.aborted) {
    return;
  }

  let interval: NodeJS.Timer;
  const abortController = new AbortController();

  signal.addEventListener("abort", () => {
    clearInterval(interval);
  }, {
    once: true,
    // added that
    signal: abortController.signal,
  });

  interval = setInterval(() => {
    // process something...
  }, 1000);

  try {
    await setTimeout(processTime, undefined, { signal });
  } finally {
    // abort to release the listener
    abortController.abort();
    clearInterval(interval);
  }
}

Also, note that in the case were we receive an "abort" signal, we clear the interval two times, ideally we would want avoid releasing something already released as it might produce failures, but in that case clearing a timeout that was already destroyed does literally nothing.

From the external world if we want to interrupt the computation, we also have to manipulate our own instance of the Abort Controller we then provide in the backgroundJobWithCancellation function arguments:

async function main() {
  const controller = new AbortController();

  // later in time
  setTimeoutCb(() => {
    controller.abort();
  }, 1000);

  await backgroundJobWithCancellation(controller.signal);
}

As we can see with that example, it's very easy to miss some important details in the control flow as adding listeners to manage cancellation is also adding a resource that can originate memory leaks. When nesting and having child computations, you must ensure to propagate the parent signal in all the computation tree, making it tedious and very hard to maintain properly.

Let's see now the difference using Effect and how it deals with interruptions. Effect embeds its own way of scheduling timers, the prefered way to do that would be to use Effect.repeat (an example is available in the 03-resilience.ts source file).

Just so that the example remains simple with the same timer API, let's keep using setInterval.

const backgroundJob = pipe(
  Effect.asyncInterrupt(() => {
    const timer = setInterval(() => {
      console.log("processing job...");
    }, 500);

    return Effect.sync(() => {
      console.log("releasing resources...");
      clearInterval(timer);
    });
  })
);

asyncInterrupt allows us to way to describe an asynchronous side-effect plus offers us the control over its interruption. The Effect returned in the asyncInterrupt will be executed in case backgroundJob gets interrupted.

Using Effect, we have a guarantee that the release Effect returned by the asyncInterrupt method will be executed. The interruption model allows us to have a straightforward but also a fine-grained control over interruptibility.

To simulate an interruption happening, we can manually interrupt the Fiber currently running the background job.

An Effect is always run in a Fiber. The runtime has always atleast one root Fiber to run Effects. In the case where you want to model concurrent operations, you should favor high-level operators such as zipPar, forEachPar because Fibers are low-level constructs so you don't usually need to manipulate them directly. See more in the Concurrency part.

pipe(
  backgroundJob,
  // Fork the execution of the job in a child Fiber
  Effect.fork,
  // Forking gives us a reference to the child Fiber
  Effect.flatMap((fiberId) =>
    pipe(
      // After 2 seconds, we arbitrarily interrupt the child Fiber 
      Fiber.interrupt(fiberId),
      Effect.delay(Duration.seconds(2))
    )
  ),
  // Run the program
  Effect.runFork
);

This case is trivial, but what's great is that Effect simplifies a lot the way interruptibility is managed through chains of computations with a nice control flow and very rich semantics. As it was shown when using the Abort Controller API, things can become quickly verbose, messy and error-prone as signals must be handled, propagated, "abort" event handlers must be cleaned up, etc. On the contrary, the Effect runtime manages all that for us so we just have to manage our cancellation logic. Moreover, that powerful interruptibility management also applies to Layers where our application dependencies live.

Note: more interrupt examples are available in the src/03-resilience.ts TypeScript file, involving interruptions of nested computations and different ways of reacting following interruptions.

4. Composability

The art of having a set of reusable software components that can be easily combined, extended, specialized and in a scalable, maintainable and understable way.

Effect does exactly that. Thanks to all its primitives and very rich standard library, it allows us to model everything we need on a daily basis.

Hopefully at this point you already read everything before that, do I still need to justify the fact that Effect leverages incredibly well composability? 🥱

const ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const schedulePolicy = pipe(
  Schedule.exponential(Duration.seconds(1), 0.5),
  Schedule.compose(Schedule.elapsed),
  Schedule.whileOutput(Duration.lessThanOrEqualTo(Duration.seconds(5)))
);

const listTodos = pipe(
  TodosRepository,
  Effect.flatMap((todosRepository) =>
    pipe(
      ids,
      Effect.forEach(
        (id) => pipe(todosRepository.fetchTodo(id), Effect.retry(schedulePolicy)),
        {
          concurrency: 5
        }
      )
    )
  ),
  Effect.tapError(({ id }) => 
    pipe(`Error fetching ${id}`, Effect.log({ level: "Error" }))
  )
);

This little example takes just a bit more than 20 lines of code but it combines:

  • requesting access to the TodosRepository
  • using that repository to combine a set of 10 operations bounded by a limit of 5 simultaneous operations
  • each operation uses a retry schedule with exponential backoff in case of failure, bounded by a maximum period of 5 seconds after what the retry stops

What's great is that the same composition principles smoothly apply on all modules because Effect implements the hard Functional Programming laws under the hood for us. Effect provides us a very rich set of building blocks that we can compose as much as we want, we can reduce or increase abstractions as we want, without having to generalize at the cost of a maintenance burden. See how much I was able to define a specific schedule without having to write any piece of that logic myself. I'm just composing Schedule blocks, providing that in a computation context.

For instance, the @effect/schema library created by Giulio Canti integrates super nicely, let's take a look at a simple TodosRepository implementation:

import { Effect, Layer, Context, pipe } from "effect";
import * as S from "@effect/schema/Schema";

const Todo = S.struct({
  id: S.number,
  completed: S.boolean,
});

type Todo = S.Schema.To<typeof Todo>;

class FetchError {
  readonly _tag = "FetchError";
  constructor(readonly id: number) {}
}

class DecodeError {
  readonly _tag = "DecodeError";
  constructor(readonly id: number) {}
}

interface TodosRepository {
  fetchTodo: (id: number) => Effect.Effect<TodosRepository, FetchError, Todo>;
}

const TodosRepository = Context.Tag<TodosRepository>();

const TodosRepositoryLive = Layer.succeed(TodosRepository, {
  fetchTodo: (id) =>
    pipe(
      Effect.tryPromise({
        try: () =>
          fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(
            (response) => response.json()
          ),
        catch: () => new FetchError(id),
      }),
      Effect.flatMap(S.decode(Todo)),
      Effect.mapError(() => new DecodeError(id))
    ),
});

See how everything integrates well including async operation, schema validation, multiple error mapping in 10 explicit lines of code.

Note also that Effect code can be written with various styles. In most examples here, I'm using the pipeable API, but at some places I'm also using the dual API and the imperative-like syntax with Generators. And you know what? You can even combine these three styles combined altogether (I'll let you settle if it's a good practice on your own).

5. Concurrency

Concurrency is the art of running multiple computations cooperatively to improve the overall speed of the program execution.

"concurrency is about dealing with lots of things at once", Rob Pike

Node.js is an example of a runtime leveraging concurrency on a single thread using an Event Loop to cooperatively execute asynchronous task.

Reminder: Doing a synchronous operation is faster than doing an asynchronous operation. But when combining multiple operations this is where concurrency becomes interesting.

Concurrency is very hard to do right

Issues with Concurrency

  • Hard to get a deterministic execution model
  • Shared resource problems
  • Deadlocks, resource starvation can occur
  • Memory/CPU efficiency
  • ... many more

The Dining philosophers problem, introduced in 1965 by Edsger Dijkstra

https://en.wikipedia.org/wiki/Dining_philosophers_problem

bg left

Earlier with JavaScript we talked about Promises that could be used to model asynchronous computations.

The problem: Promises don't have any builtin way of having a fine-grained control over concurrency

We can handle concurrency very easily!

🦦 Promise.all, Promise.allSettled

Promise.all and Promise.allSettled both allow you to run concurrently X operations but:

  • no easy way of having a bounded concurrency
  • no resource safety, all other Promises keep being executed in the background in case of failures (even in case of success for Promise.any or Promise.race)
  • no easy way of handling interruptions

Bounded vs Unbounded concurrency

Bounded can be used to qualify a limited resource in terms of memory space, memory usage, cpu usage, anything that should be limited (bounded).

  • Bounded concurrency is the art of controlling how much operations can run concurrently.
  • Unbounded concurrency is the opposite, that is using Promise.all 😄

Example of an Unbounded concurrency case

const userIds = Array.from({ length: 1000 }, (_, idx) => idx);

function fetchUser(id: number): Promise<User> {
  // 
}


function retrieveAllUsers() {
  return Promise.all(
    userIds.map((id) => fetchUser(id))
  );
}

All Promises were spawned at the same time, blowing up both the Event Loop and the CPU.

bg left

Effect allows us to control the number of concurrent operations very easily:

pipe(
  userIds,
  Effect.forEach((id) => Effect.promise(() => fetchUser(id)), {
    concurrency: 30
  })
);

And also allows to deal more advanced patterns with built in modules:

  • STM (Software Transactional Memory): Transactional Data Structures & Coordination
  • Semaphore: Concurrency Control

Promises: no resource safety/management. Even when the Promise fulfills, the other ones keep running in the background. This can become a problem if the scheduling of leaking Promises is done a lot. It will blow up the CPU and load the Event Loop with unecessary work.

function quickRunningPromise() {
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

function longRunningPromise() {
  return new Promise((resolve) => {
    setTimeout(resolve, 5000);
  });
}

Promise.race([quickRunningPromise(), longRunningPromise()]);

Effect are by nature interruptible, meaning that all these operations are ressource-safe.

const quickRunningEffect = pipe(
  Effect.delay(Duration.seconds(1))(Effect.unit)
);

const longRunningEffect = pipe(
  Effect.delay(Duration.seconds(5))(Effect.unit),
  Effect.onInterrupt(() => {
    console.log("interrupted!");
    return Effect.unit;
  })
);

Effect.runCallback(
  Effect.race(quickRunningEffect, longRunningEffect),
  () => {
    console.log("done");
  }
);

One great thing is the Effect runtime will automatically perform cleanup/release of the underlying tasks once a computation is interrupted.

Remember the previous example?

const interruptibleEffectWithAutoCleanup = Effect.asyncInterrupt(() => {
  const timer = setInterval(() => {}, 1000);
  // Cleanup/Release function
  return Effect.sync(() => {
    console.log("clear interval");
    clearInterval(timer);
  });
});

If we race something with that Effect and it loses, the cleanup function will be automatically called, by default in the background (asynchronously) or can be a blocking operation.

There are a lot of features around that, that are out of the scope of the introduction.

6. Efficiency & Performance

Efficiency and Performance are both related and unrelated at the same time.

  • Performance: refers to how well a task is completed within a given time frame or how quickly a system can complete a task
  • Efficiency: refers to the ratio of the output or result to the resources used to produce it

Achieving both requires careful consideration of tradeoffs and goals.

For instance, it's easy to nearly blow up the stack while the program is very performant.

Remember the Promise.all example? It will most likely execute faster than the Effect version, but the overall Performance of the program will be impacted (other tasks will take longer time) and Efficiency-wise, it's not ideal.

Effect in essence

Before going into that subject of Efficiency & Performance, it's important to understand the foundations of Effect.

Effect is in the first place an Embedded Domain Specific Language (DSL). It uses TypeScript (Embedded) to describe a specific set of instructions that will be interpreted by a runtime (Effect Runtime). We call that DSL encoding initial.

Effect is simply an Embedded Domain Specific Language with Initial encoding!

Here is an example of a simple React DSL that helps us build Tables.

  <Table<BrandPerformanceTurnover>>
    <Row>
      <Cell<BrandPerformanceTurnover>
        title={'something'}
        sort="enabled"
      />
    </Row>
  </Table>

Unlike Effect, this Table DSL is using a Final encoding meaning that the description is defined in terms of it's direct interpretation. In that case it means that there is a parent component that aims as an interpreter and will introspect all the children. The description is tighted to it's interpretation, not letting any room for multiple interpretations, optimizations and can be unsafe (Tables are not really concerned by that).

If you want to know more about TypeScript DSLs, here is an excellent blog post from Michael Arnaldi, the creator of Effect

bg left 50%

Let's demystify Effect

Effect Systems

Effect is just a description! All data types are used to model a set of computations that represent our program.

One of the biggest strengths of Effect is that without even executing anything, by just leveraging mathematical concepts and the TypeScript compiler, it is already a proof of whether the program is correct or not.

The whole purpose of Effect Systems that aim to represent side-effectful operations that a program might process at some point. The objective is to have a full control and defer at the most end the execution of all those side effects, when the program was understood by both the compiler and the runtime.

It lets room for a lot of performance, composability, substitutions, optimizations, type-safety, stack-safety, concurrency...

See this brilliant article from John A. De Goes, the creator of ZIO: no-effect-tracking

Effect Systems

Functional effect systems like ZIO (and Haskell’s IO data type) let us take side-effects and make them more useful, by turning them into values, which we can transform and compose, solving complex problems with easy and type-safe combinators that simply can’t exist for side-effecting statements

John A. De Goes, (creator of ZIO, Effect ancestor)

Now that we have described our program using the Effect DSL, how to execute the underlying computation?

We need an Interpreter!

Let's do that.

Effect Runtime

Effect comes in with a builtin fiber-based Runtime that interprets the description. A Fiber is a lightweight primitive that deals efficiently with concurrency, scheduling, resource management, interruption etc. It is often refered as a virtual thread / green thread, meaning that it leverages cooperative multitasking using its own computational context that runs independently but that can easily be joined/forked/resumed/stopped/interrupted...

Thousands and even millions of Fibers can be spawned and run in a single thread. They are much more lightweight and efficient than operating system threads. Fibers can also be dispatched to be executed within many operating system threads (ZIO uses a threadpool).

Also: Go implements its own native version of green threads using goroutines Kotlin, Java have their own green threads implementation for instance Erlang... C#...

Let's see how to use the built in Effect runtime! (src/06-runtime.ts)

Other interesting facts about the fiber-based runtime:

  • It's stack safe, because it controls the execution of operations, it's able to determine how much operations it can execute on the current tick of the Event Loop.
  • It's memory efficient because after X operations (currently 2048), the fiber yields, letting the Event Loop breath and letting other tasks run.
  • It's overall faster, because the runtime can combine/batch/eliminate operations and tries to leverage synchronous operations as much as it can.

A Fiber can be thought of as a virtual thread that emulate the same behavior as OS threads with nicer abstractions and without the platform constraints, for instance thousands of virtual threads can be run efficiently in a single-thread allocated to run application code, like Node.js by default (putting worker threads aside). In the context of ZIO, Fibers are scheduled within an OS thread pool running on the JVM.

Like threads, Fibers are low-level constructs so you don't usually need to manipulate them directly. Instead, you can use a very rich set of concurrent primitives directly using Effects, these can be used through options provided to Effect combinators e.g. Effect.forEach(() => {}, { concurrency: 30 }).

7. Tracing & Logging

Effect also directly embeds primitives for Logging, Tracing and even Metrics.

It can be integrated with OpenTelemetry, Prometheus, etc.

Summary

  • Explicitness ✅
  • Testing ✅
  • Resilience ✅
  • Composability ✅
  • Concurrency ✅
  • Efficiency & Performance ✅
  • Tracing & Logging ✅

All that in just one tool, with TypeScript.

The standard library is very rich and we didn't cover everything there! In addition to the standard library, there is also a set of useful additional packages that you can find in the "API Reference" section of the Effect documentation

Here is an overview and non-exhaustive list:

And many more to come!

Other resources:

About

Effect introduction about the whys, helping transitioning from raw TypeScript to Effect TypeScript

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published