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:
The news is out! 📢 We have made the decision to unite and collaborate on *Effect* as a generational project that aims to create a more cohesive and powerful ecosystem of libraries that are accessible and useful to developers of all levels. #TypeScript
A bit of an announcement: fp-ts merging into @EffectTS_ together with @GiulioCanti dev.to/effect-ts/a-br…
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!
Also we are now a single team, dev.to/effect-ts/a-br… we started with the idea of supporting an fp-ts integration but ended up realizing a single ecosystem is in better so Giulio is now working on making Effect great, starting with the docs & with schema (the successor of io-ts)
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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
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):
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
).
{
// ...
"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:
- Extract the
email
from theRequest
- Validate the
email
(must be astring
and must have the correct regex formatting) - Make a request to Supabase authentication to register a new user
- 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 "effect";
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 tochain
.
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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
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:
export interface ValidationService {
readonly parseEmail: (
email: string
) => Either.Either<ParseResult.ParseError, EmailBrand>;
}
You then assign a unique Tag
to the service, using Context.Tag
:
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 toContext.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 asService 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):
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");
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:
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
:
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 dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
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 aConfigError
to the union of possible errors, which marks the case in which theConfig
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.