From fp-ts to effect-ts: How to guide

Sandro Maglione

Sandro Maglione

Functional programming

I have been using fp-ts in all of my projects for years now.

In the second half of 2022 I was constantly checking Twitter for updates on the release of fp-ts v3. Meanwhile, another library for "functional programming", Effect, was spearheading the advancement of what is possible with typescript.

Then, early this year, the communities behind fp-ts and Effect decided to join forces: no more fp-ts v3 but all-in all-together on Effect:

Since then the progress has been immense. As I am writing this the official documentation website of Effect is evolving fast, with new PRs merged every day!

It's about time to migrate! In this post I share my experience migrating my personal website (the one you are reading right now) from fp-ts to Effect.

I will focus specifically on the newsletter sign up route, which is the major "backend" functionality.

By the way, you can subscribe here below πŸ‘‡

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 400+ readers.

Setting up Effect

First step, removing fp-ts from your dependencies and adding both @effect/data and @effect/io:

npm uninstall fp-ts
npm install @effect/io @effect/data

I also installed @effect/schema for schema validation (specifically, email formatting):

npm install @effect/schema

When working with Effect I like the convention of creating a commmon.ts file which exports the Effect modules that I use in the project.

I initially export only the Effect module, and then I gradually add new exports as I need them.

For reference, below is how the file looks at the end of the migration πŸ‘‡

The highlighted lines are the modules I consider "essential" (the ones I used the most):

common.ts
export * as Effect from "@effect/io/Effect";
export * as Context from "@effect/data/Context";
export * as Layer from "@effect/io/Layer";
export * as Schema from "@effect/schema/Schema";
export * as Either from "@effect/data/Either";
export { flow, identity, pipe } from "@effect/data/Function";
export * as Boolean from "@effect/data/Boolean";
export * as Number from "@effect/data/Number";
export * as ReadonlyArray from "@effect/data/ReadonlyArray";
export * as String from "@effect/data/String";
export * as Equivalence from "@effect/data/typeclass/Equivalence";
export * as Order from "@effect/data/typeclass/Order";
export * as Config from "@effect/io/Config";
export * as ConfigError from "@effect/io/Config/Error";
export * as Logger from "@effect/io/Logger";
export * as LoggerLevel from "@effect/io/Logger/Level";
export * as Match from "@effect/match";
export * as ParseResult from "@effect/schema/ParseResult";

To avoid conflicts with module names, it is recommended to use the import/export * as ... syntax when importing modules (recommended in the documentation)

Finally, remember to add "exactOptionalPropertyTypes": true as well as "strict": true to your tsconfig.json (requirements for @effect/schema).

tsconfig.json
{
  // ...
  "compilerOptions": {
    // ...
    "strict": true,
    "exactOptionalPropertyTypes": true
  }
}

How the API works

The app is based on nextjs 13, using the newest app directory.

The core services for the newsletter are supabase and convertkit.

The newsletter sign up code consist in a single api route that does the following:

  1. Extract the email from the Request
  2. Validate the email (must be a string and must have the correct regex formatting)
  3. Make a request to Supabase authentication to register a new user
  4. Make a request to the Convertkit API to sign up the user to the newsletter

The route then returns either true if all the steps were successful, or a readable error message with status 500 otherwise.

Mapping fp-ts code to Effect

The first iteration consists of mapping fp-ts types to the equivalent in Effect, without any major architectural changes.

From Option to Schema

I was using Option from fp-ts to validate the email formatting. Specifically, Option.fromPredicate, checking a custom email regex:

import * as O from "fp-ts/Option";
 
// ...
 
O.fromPredicate((email) => emailValidationRegex.test(email))

I now achieve the same (better ☝️) using Schema from @effect/schema.

I validate the email and construct a branded type:

import { Schema, pipe } from "./common";
 
export const EmailBrand = pipe(
  Schema.string,
  Schema.filter((str) =>
    /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i.test(
      str
    )
  ),
  Schema.brand("EmailBrand")
);
 
export type EmailBrand = Schema.To<typeof EmailBrand>;

Effect instead of TaskEither: Effect.gen

The API used TaskEither from fp-ts to perform async requests and catch errors.

Specifically, it used TaskEither.tryCatch to perform requests, and TaskEither.chain to compose them in sequence:

pipe(
  TE.tryCatch(
    // Supabase
    async () => {
      // ...
 
      return validEmail;
    },
    () => "Error"
  ),
  TE.chain((validEmail) =>
    // Converkit
    TE.tryCatch(
      () => {
        // ...
      },
      () => "Error"
    )
  )
);

When using the Effect library everything is an Effect. I used Effect.gen to implement the API (Generator API).

Effect.gen lets you write code that looks "imperative" (linear) instead of nesting calls to chain.

I then used Effect.tryCatchPromise to perform async requests (equivalent to tryCatch in fp-ts):

const signUpRequest = (req: Request) =>
  Effect.gen(function* (_) {
    const validEmail: EmailBrand = yield* _(...); // `Schema` validation
 
    yield* _(Effect.tryCatchPromise(...)); // Supabase
    yield* _(Effect.tryCatchPromise(...)); // Convertkit
 
    /// ...
  });

Running an Effect using Runtime

One important difference between fp-ts and Effect is executing a computation.

In fp-ts I was used to just convert TaskEither to Task (usually using match), and, since Task is a simple thunk, I would then execute it like a normal function:

const result = await pipe(
  // ...
  TE.match(
    // ..
  )
)(); // πŸ‘ˆ running the `Task` returns a `Promise`

In Effect instead to run an Effect you need a Runtime.

The Effect type provides some default runtime used in most cases, like runPromise and runSync.

In my case, since the API is async, I used runPromise:

const result = await pipe(
  signUpRequest(req),
  // ...
  Effect.runPromise
);

This is everything that was "needed" from fp-ts for the API route.

Nonetheless, Effect offers a lot more than this! Since I wanted to over-engineer this implementation I dived deeper into every functionality provided by the library to enhance this endpoint.

Let's see each new feature step by step πŸ‘‡

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 400+ readers.

Services and Layer: dependency injection in Effect

Problem: The API mixes together multiple services (Supabase, Converkit, validation), without a clear separation. This makes them hard to implement in isolation and impossible to use/test alone.

The previous implementation was missing dependency injection. All requests were implemented directly inside the API route:

  • Impossible to test, since you cannot mock the implementation of each request from the outside
  • Impossible to implement and use each service in isolation, since they are all mixed together inside the API

Effect solves this problem by introducing Services and Layers.

Service example: Validation service

For example, I separated the validation code in its own service.

You start by defining a simple typescript interface containing all the methods implemented by the service:

validation-service.ts
export interface ValidationService {
  readonly parseEmail: (
    email: string
  ) => Either.Either<ParseResult.ParseError, EmailBrand>;
}

You then assign a unique Tag to the service, using Context.Tag:

validation-service.ts
export interface ValidationService {
  readonly parseEmail: (
    email: string
  ) => Either.Either<ParseResult.ParseError, EmailBrand>;
}
 
export const ValidationService = Context.Tag<ValidationService>("@app/ValidationService");

It is recommended to always add a string key to Context.Tag ("@app/ValidationService" in the code example above πŸ‘†).

This prevents duplication and at the same time allows the library to better print a tag. For example, if you ever face the Service Not Found error, by adding the key the message will be reported as Service Not Found: @app/ValidationService.

This is all you need to define a basic service. As you can see, we did not provide an implementation yet.

In fact, a service defines the methods and types of an API, while the implementation will be provided later, based on the environment (production, development, testing) and other factors.

Layer: organize dependencies between services

The API also requires a service for Supabase and Converkit, defined as follows (for reference):

supabase-service.ts
export interface SupabaseService {
  readonly signUp: (
    email: string
  ) => Effect.Effect<
    never,
    | UnexpectedRequestError
    | QueryRequestError
    | ParseResult.ParseError
    | ConfigError.ConfigError,
    true
  >;
}
 
export const SupabaseService = Context.Tag<SupabaseService>("@app/SupabaseService");
converkit-service.ts
export interface ConvertkitService {
  readonly signUp: (
    email: string
  ) => Effect.Effect<
    never,
    | NewsletterSignUpUnexpectedError
    | NewsletterSignUpResponseError
    | ParseResult.ParseError
    | ConfigError.ConfigError,
    true
  >;
}
 
export const ConvertkitService = Context.Tag<ConvertkitService>("@app/ConvertkitService");

Both the Supabase and Converkit services must have a dependency on the Validation service, since they both need to validate the email before making a request.

This is where we are going to use Layer:

The Layer module is used to manage complex dependencies between services.

We first create a Layer for the validation service, providing a concrete implementation for the parseEmail method:

// `-Live` suffix marks the implementation for the production (live) environment
export const ValidationServiceLive: Layer.Layer<never, never, ValidationService> = Layer.succeed(
  ValidationService,
  ValidationService.of({
    parseEmail: Schema.parseEither(EmailBrand),
  })
);

We do the same for Supabase (SupabaseServiceLive) and Converkit (ConvertkitServiceLive), but this time we use Layer.effect to provide the ValidationService dependency:

supabase-service.ts
export const SupabaseServiceLive: Layer.Layer<ValidationService, never, SupabaseService> = Layer.effect(
  SupabaseService,
  Effect.map(
    ValidationService, // πŸ‘ˆ Dependency injection
    (validation) =>
      SupabaseService.of({
        signUp: (emailRaw) =>
          Effect.gen(function* (_) {
            const email: EmailBrand = yield* _(validation.parseEmail(emailRaw));
 
            // ...
          }),
      })
  )
);

Finally, we can compose each layer using Layer.merge and Layer.provide:

// Supabase + Convertkit layers (dependency on `ValidationService`)
const merge: Layer.Layer<
  ValidationService,
  never,
  SupabaseService | ConvertkitService
> = Layer.merge(SupabaseServiceLive, ConvertkitServiceLive);
 
// Provide dependency on `ValidationService` for both Supabase and Convertkit
const layerLive: Layer.Layer<never, never, SupabaseService | ConvertkitService> = pipe(
  ValidationServiceLive,
  Layer.provide(merge)
);

Now we can provide this layer to the final API implementation using Effect.provideLayer:

const effect = pipe(
  signUpRequest(req),
  Effect.provideLayer(layerLive),
  // ...

Layer will now manage all the services and their dependencies.

Everything is now easy to test by simply providing an alternative implementation for each layer (ValidationServiceTest, SupabaseServiceTest, ConvertkitServiceTest, composed together in a layerTest instead of layerLive):

const layerTest: Layer.Layer<never, never, SupabaseService | ConvertkitService> = pipe(
  ValidationServiceTest,
  Layer.provide(Layer.merge(SupabaseServiceTest, ConvertkitServiceTest))
);

Better errors with Effect and tags

The Effect type keeps track of errors in the error channel, which corresponds to the second generic type in Effect<Context, Error, Value>.

We saw an example previously in SupabaseService and ConverkitService:

supabase-service.ts
export interface SupabaseService {
  readonly signUp: (
    email: string
  ) => Effect.Effect<
    never,
    | UnexpectedRequestError
    | QueryRequestError
    | ParseResult.ParseError
    | ConfigError.ConfigError,
    true
  >;
}

All the possible errors are collected directly inside the interface definition.

To define each error I used a class with a _tag parameter, used by Effect to distinguish between each error:

export class UnexpectedRequestError {
  readonly _tag = "UnexpectedRequestError";
  constructor(readonly error: unknown) {}
}
 
export class QueryRequestError {
  readonly _tag = "QueryRequestError";
  constructor(readonly error: unknown) {}
}

Now when we encounter or report an error (for example using Effect.fail or Either.left) the error type will be added to the final Effect type:

const response = yield* _(
  Effect.tryCatchPromise(
    () => {
      // ...
    },
    (error) => new UnexpectedRequestError(error)
  )
);

In Effect we can then handle all possible errors by using Effect.catchTag, Effect.catchTags, Effect.mapError and more:

const effect = pipe(
  signUpRequest(req),
  Effect.provideLayer(layerLive),
  Effect.mapError((error) => {
    // Convert all errors to another type
  }),
  Effect.catchAll((error) => {
    return Effect.succeed(
      // Catch all errors and move them to the "succeed" channel
    );
  })
);

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 400+ readers.

Environmental variables using Config

Another issue with the previous implementation was providing environmental variables to the API. In fact, both Supabase and Converkit require both an API key and url.

I was defining global variables for all these configuration parameters. I was then using these variables directly inside the API implementation:

// Global variable, throw when missing πŸ’πŸΌβ€β™‚οΈ
const CONVERTKIT_API_KEY = process.env.CONVERTKIT_API_KEY;
if (!CONVERTKIT_API_KEY) throw new Error("Missing env.CONVERTKIT_API_KEY");

Once again, this implementation does not allow to change or inject these variables from the outside, causing problems with testing.

Effect to the rescue using Config:

The Config module is used to provide configuration parameters to an effect.

You define a Config variable which collects all the configuration parameters. These parameters can be primitive values (string, using Config.string) but they can also be composed in more complex types (using Config.all, Config.zip, Config.map).

In my case, I used all these functions to build a Config for convertkit:

class ConvertkitConfig {
  constructor(readonly url: string, readonly formId: string) {}
 
  public get fetchUrl(): string {
    return `${this.url}forms/${this.formId}/subscribe`;
  }
}
 
const config: Config.Config<[string, ConvertkitConfig]> = Config.all(
  Config.string("CONVERTKIT_API_KEY"),
  pipe(
    Config.zip(
      Config.string("CONVERTKIT_API_URL"),
      Config.string("CONVERTKIT_FORM_ID")
    ),
    Config.map(([url, formId]) => new ConvertkitConfig(url, formId))
  )
);

You can then access these parameters directly inside the Layer implementation:

export const ConvertkitServiceLive = Layer.effect(
  ConvertkitService,
  Effect.map(ValidationService, (validation) =>
    ConvertkitService.of({
      signUp: (emailRaw) =>
        Effect.gen(function* (_) {
          const [apiKey, convertkitConfig] = yield* _(Effect.config(config));
 
          // ...
        }),
    })
  )
);

Accessing a Config adds a ConfigError to the union of possible errors, which marks the case in which the Config value is missing.

export interface ConvertkitService {
  readonly signUp: (
    email: string
  ) => Effect.Effect<
    never,
    | NewsletterSignUpUnexpectedError
    | NewsletterSignUpResponseError
    | ParseResult.ParseError
    | ConfigError.ConfigError,
    true
  >;
}

Note: In this example, Effect will access these parameters from process.env

Logging using Logger

Another useful feature I added is logging. Logs in Effect are implemented using the Logger module.

Logger among other things allows to define the log level and customize the formatting of the output logs .

You can use Effect logging to integrate Logger with Effect. Specifically, I used the Effect.logDebug method to print some message in between each request:

const signUpRequest = (req: Request) =>
  Effect.gen(function* (_) {
    const supabase = yield* _(SupabaseService);
    const convertkit = yield* _(ConvertkitService);
 
    const { email } = yield* _(
      Effect.tryCatchPromise(
        async () => req.json(),
        (error) => new JsonParsingError(error)
      )
    );
 
    yield* _(Effect.logDebug(`Successfully parsed response from json (email: "${email}")`));
 
    const emailRaw = yield* _(
      typeof email === "string"
        ? Effect.succeed(email)
        : Effect.fail(new MissingEmailError())
    );
 
    yield* _(Effect.logDebug(`Found email in request (email: "${emailRaw}")`));
 
    yield* _(supabase.signUp(emailRaw));
    yield* _(convertkit.signUp(emailRaw));
 
    return true as const;
  });

logDebug prints logs at the debug log level (see the Logger/Level module).

This means that by default you will not see these messages. Instead, you need to change the log level to debug using Logger.withMinimumLogLevel:

// Change log level to `Debug` (for testing and development ☝️)
Logger.withMinimumLogLevel(LoggerLevel.Debug)

Now you will see the messages printed in the console.

Pattern matching using @effect/match

The Effect ecosystem provides another magic library called @effect/match.

@effect/match brings pattern matching to typescript. In my case, this library is ideal for pattern matching on each possible error to provide a readable error message.

npm install @effect/match

Note ☝️: Since version v0.19.0 @effect/match requires Typescript v5 or above.

I used Effect.mapError to convert each error in the error channel to a string:

Effect.mapError(
  flow(
    Match.value,
    Match.when(
      { _tag: "JsonParsingError" },
      () => "..."
    ),
    Match.when(
      { _tag: "MissingEmailError" },
      () => "..."
    ),
    Match.when(
      { _tag: "ParseError" },
      () => "..."
    ),
    Match.when(
      { _tag: "UnexpectedRequestError" },
      () => "..."
    ),
    // ...
    Match.orElse(() => "Unknown error (a bug 🐞), please try again πŸ™πŸΌ")
  )
)

In this example I used Match.orElse to return a default error message ("catch-all").

The library also provides Match.exhaustive which will report a compile error when you forget to match a value.

I then simplified the implementation even further by using Match.tags, which allows to pattern match on values that have a _tag field:

Effect.mapError(
  flow(
    Match.value,
    Match.tags({
      JsonParsingError: () => "...",
      MissingEmailError: () => "...",
      ParseError: () => "...",
      UnexpectedRequestError: () => "...",
      // ...
    }),
    Match.orElse(() => "Unknown error (a bug 🐞), please try again πŸ™πŸΌ")
  )
)

Hint πŸ’‘: If you don't know how an API works, you can check the tests in the repository on Github (as I did for Match.tags)


As you saw, Effect provides all you need (and more 🌍) to implement all the usecases in your app with a solid, extensive, and functional API.

This was just a short overview of what Effect has to offer. Nonetheless, we could appreciate the improvements that Effect allowed compared to my previous implementation with fp-ts.

I plan to write a lot more about Effect in future articles. If you are interested, I encourage you to join the community in the official Discord channel of Effect.

You can also subscribe to my newsletter here below πŸ‘‡

I am going to share tips and code snippets about Effect (and more broadly functional programming) as I use and learn more about the library.

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 400+ readers.