Learn how to use Effect to perform a request to subscribe a user to your newsletter on ConvertKit:
- Define component to collect user email
 - Perform API request to sign up the user to the newsletter
 - Implement the code to subscribe the user using Effect
 
Project configuration
The project uses nextjs.
Note:
nextjsis not required, but it is convenient since it allows to implement both client code and API routes
Run the create-next-app command:
npx create-next-app@latestNote: The project on Github has no styling (no tailwindcss). This is intentional, since the focus is more on Effect and ConvertKit.
I suggest you to then switched to use pnpm instead of npm as package manager.
You can install
pnpmfrom homebrew on my Mac:brew install pnpm
You can add a preinstall script to ensure that all command will be run using pnpm instead of npm:
"scripts": {
  "preinstall": "npx only-allow pnpm",
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}Install dependencies
The dependencies for this project are effect, @effect/schema and @effect/platform:
effect: Core of the Effect ecosystem@effect/schema: Define and use schemas to validate and transform data@effect/platform: API interfaces for platform-specific services with Effect (used for HTTP requests in the project)
Run the install command:
pnpm install effect @effect/schema @effect/platformFolder structure
The project contains 2 main folders:
app:nextjs's page and layoutlib: Implementation of the API to subscribe to the newslettertest: Tests definition and configuration
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
Starting point: Services in Effect
The first step in all Effect projects is defining a Service.
A Service in Effect is a Typescript
interfacedefining the API of the application.
Our application has only one method addSubscriber that allows to subscribe an email to ConvertKit:
import { Effect } from "effect";
export interface ConvertKitService {
  /**
   * Add new subscriber with given `email`.
   */
  readonly addSubscriber: (
    email: string
  ) => Effect.Effect<never, never, string>;
}addSubscriber returns an Effect type. Effect.Effect<never, never, string> has 3 type parameters:
- Requirements (
never) - Errors (
never) - Return value (
string) 
We are going to define these 3 types more specifically later on, for now we can leave the definition as
Effect.Effect<never, never, string>
Context for a Service
Every service in Effect is assigned a tag using Context.
A Tag serves as a representation of the ConvertKitService service. It allows Effect to locate and use this service at runtime:
import { Context, Effect } from "effect";
export interface ConvertKitService {
  /**
   * Add new subscriber with given `email`.
   */
  readonly addSubscriber: (
    email: string
  ) => Effect.Effect<never, never, string>;
}
export const ConvertKitService = Context.Tag("@app/ConvertKitService");Note: We did not define yet a concrete implementation of the service. This is intentional. A service is just the definition of the API, we are going to implement it below using
Layer
Config: Environmental variables
A request to the ConvertKit API requires 3 configuration values:
- API URL (
https://api.convertkit.com/v3) - Form ID
 - API key
 
curl -X POST https://api.convertkit.com/v3/forms/<form_id>/subscribe\
     -H "Content-Type: application/json; charset=utf-8"\
     -d '{ \
           "api_key": "<your_public_api_key>",\
           "email": "[email protected]"\
         }'We define these 3 parameters using Config from Effect.
First we define a class ConvertKitConfig that exposes the parameters required for the request:
export class ConvertKitConfig {
  constructor(
    readonly apiKey: string,
    readonly url: string,
    readonly formId: string
  ) {}
  public get fetchUrl(): string {
    return `${this.url}forms/${this.formId}/subscribe`;
  }
  public get headers() {
    return {
      "Content-Type": "application/json",
      charset: "utf-8",
    };
  }
}We then use Config to collect apiKey, url and formId and create an instance of ConvertKitConfig:
Config.allcollects all the configuration values (in this case 3 strings defined usingConfig.string)Config.mapextracts the values and converts them to a valid instance ofConvertKitConfig
import { Config } from "effect";
export const config = Config.all([
  Config.string("CONVERTKIT_API_KEY"),
  Config.string("CONVERTKIT_API_URL"),
  Config.string("CONVERTKIT_FORM_ID"),
]).pipe(
  Config.map(
    ([apiKey, url, formId]) => new ConvertKitConfig(apiKey, url, formId)
  )
);Validate response using Schema
The ConvertKit API request when successful returns the information of the new subscribed user in the following format:
{
  "subscription": {
    "id": 1,
    "state": "inactive",
    "created_at": "2016-02-28T08:07:00Z",
    "source": null,
    "referrer": null,
    "subscribable_id": 1,
    "subscribable_type": "form",
    "subscriber": {
      "id": 1
    }
  }
}We use Schema to validate the response type. In our example we collect only the two id:
import * as Schema from "@effect/schema/Schema";
const SubscribeResponse = Schema.struct({
  subscription: Schema.struct({
    id: Schema.number,
    subscriber: Schema.struct({ id: Schema.number }),
  }),
});
export interface SubscribeResponse
  extends Schema.Schema.To<typeof SubscribeResponse> {}Tip: Check out QuickType to convert a JSON definition to its corresponding
Schema
The request also requires 2 parameters in the body:
api_keyemail
We define these parameters using Schema as well:
import * as Schema from "@effect/schema/Schema";
export const SubscribeRequest = Schema.struct({
  api_key: Schema.string,
  email: Schema.string,
});Request implementation: Layer and HttpClient
We are now ready to define the concrete implementation of ConvertKitService using Layer.
Layers are a way of separating implementation details from the service itself.
Layers act as constructors for creating the service.
We use Layer.succeed since ConvertKitService is a simple service without any dependencies.
Layer.succeed requires the service Context as first parameter and a concrete implementation of the service as second parameter (created using ConvertKitService.of):
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) => // TODO
  })
);addSubscriber implementation
addSubscriber returns an Effect. We use Effect.gen to create it:
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        // TODO
      }),
  })
);The first step is collecting the configuration parameters defined previously using Config. We use Effect.config to extract a valid instance of ConvertKitConfig:
import * as AppConfig from "./Config";
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        const convertKitConfig = yield* _(Effect.config(AppConfig.config));
      }),
  })
);The second step is defining the request. We use HttpClient from @effect/platform:
request.post:POSTrequest at the given URLrequest.setHeaders: Define headers for the requestrequest.schemaBody: Provide the body of thePOSTrequest from aSchema(SubscribeRequest)
HttpClientallows to define the request using a declarative style (URL, headers, body)
import * as Http from "@effect/platform/HttpClient";
import * as AppSchema from "./Schema";
import * as AppConfig from "./Config";
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        const convertKitConfig = yield* _(Effect.config(AppConfig.config));
        const req = yield* _(
          Http.request.post(convertKitConfig.url),
          Http.request.setHeaders(convertKitConfig.headers),
          Http.request.schemaBody(AppSchema.SubscribeRequest)({
            api_key: convertKitConfig.apiKey,
            email,
          })
        );
      }),
  })
);We can now use Http.client.fetch() to send a fetch request using the req we defined above:
Http.client.fetch()usesfetchto send the given requestresponse.schemaBodyJsonvalidates the response usingSchema(SubscribeResponse)
import * as Http from "@effect/platform/HttpClient";
import * as AppSchema from "./Schema";
import * as AppConfig from "./Config";
export const ConvertKitServiceLive = Layer.succeed(
  ConvertKitService,
  ConvertKitService.of({
    addSubscriber: (email) =>
      Effect.gen(function* (_) {
        const convertKitConfig = yield* _(Effect.config(AppConfig.config));
        const req = yield* _(
          Http.request.post(convertKitConfig.url),
          Http.request.setHeaders(convertKitConfig.headers),
          Http.request.schemaBody(AppSchema.SubscribeRequest)({
            api_key: convertKitConfig.apiKey,
            email,
          })
        );
        return yield* _(
          req,
          Http.client.fetch(),
          Effect.flatMap(
            Http.response.schemaBodyJson(AppSchema.SubscribeResponse)
          )
        );
      }),
  })
);This is it. We can now also update the service interface definition with all the errors and response type:
export interface ConvertKitService {
  /**
   * Add new subscriber with given `email`.
   */
  readonly addSubscriber: (
    email: string
  ) => Effect.Effect<
    never,
    | ConfigError.ConfigError
    | Http.body.BodyError
    | Http.error.RequestError
    | Http.error.ResponseError
    | ParseResult.ParseError,
    AppSchema.SubscribeResponse
  >;
}The Type Definition is all you need 🪄 This interface tells you everything you need to know about the API using the Effect type 💁🏼♂️ No dependencies ⛔️ List all the possible errors ✅ Returns a SubscribeResponse Type safe, easy to read and maintain 🚀
Weekly project: @ConvertKit + @nextjs + @EffectTS_ Implement your custom newsletter form using NextJs, the ConvertKit API, and Effect Interested? I will share all open source and on my newsletter 👇 sandromaglione.com/newsletter?ref…
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
API route handler
We are going to use ConvertKitService inside a nextjs API route.
A route provides a Request. We now need to validate the request and extract the email sent in the body.
We create a new main function that returns an Response inside Effect:
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    // TODO
  });The first step is extracting the body from the POST request. We define a new error for this operation using Data.TaggedError:
class RequestJsonError extends Data.TaggedError("RequestJsonError")<{
  error: unknown;
}> {}
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
  });We need to verify that jsonBody contains a valid email. We define a new schema to validate jsonBody:
import * as Schema from "@effect/schema/Schema";
export const RouteRequest = Schema.struct({
  email: Schema.string,
});We then use the schema to extract a valid email from the body:
import * as AppSchema from "./Schema";
class RequestJsonError extends Data.TaggedError("RequestJsonError")<{
  error: unknown;
}> {}
class RequestMissingEmailError extends Data.TaggedError(
  "RequestMissingEmailError"
)<{
  jsonBody: any;
  parseError: ParseResult.ParseError;
}> {}
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
  });Finally we can call addSubscriber from ConvertKitService and return a Response:
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
    const convertKit = yield* _(ConvertKit.ConvertKitService);
    const subscriber = yield* _(convertKit.addSubscriber(body.email));
    return Response.json(subscriber);
  });Handle errors and dependencies
This code does not work yet. We need to handle all errors and provide all the dependencies to satisfy the return type Effect.Effect<never, never, Response>.
main has a dependency on ConvertKitService. Therefore we use Effect.provide to pass a valid instance of ConvertKitService:
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
    const convertKit = yield* _(ConvertKit.ConvertKitService);
    const subscriber = yield* _(convertKit.addSubscriber(body.email));
    return Response.json(subscriber);
  }).pipe(Effect.provide(ConvertKit.ConvertKitServiceLive));We then need to handle all errors:
Effect.catchTagsallows to handle specific errors from their_tagEffect.catchAllallows to handle all (remaining) errors at once
export const main = (request: Request): Effect.Effect<never, never, Response> =>
  Effect.gen(function* (_) {
    const jsonBody = yield* _(
      Effect.tryPromise({
        try: () => request.json(),
        catch: (error) => new RequestJsonError({ error }),
      })
    );
    const body = yield* _(
      jsonBody,
      Schema.parseEither(AppSchema.RouteRequest),
      Effect.mapError(
        (parseError) =>
          new RequestMissingEmailError({
            parseError,
            jsonBody,
          })
      )
    );
    const convertKit = yield* _(ConvertKit.ConvertKitService);
    const subscriber = yield* _(convertKit.addSubscriber(body.email));
    return Response.json(subscriber);
  })
    .pipe(Effect.provide(ConvertKit.ConvertKitServiceLive))
    .pipe(
      Effect.catchTags({
        RequestMissingEmailError: () =>
          Effect.succeed(
            Response.json(
              { error: "Missing email in request" },
              { status: 400 }
            )
          ),
        RequestJsonError: () =>
          Effect.succeed(
            Response.json(
              { error: "Error while decoding request" },
              { status: 400 }
            )
          ),
      })
    )
    .pipe(
      Effect.catchAll(() =>
        Effect.succeed(
          Response.json(
            { error: "Error while performing request" },
            { status: 500 }
          )
        )
      )
    );That's all! Now we can use and run this Effect using runPromise inside the API route:
import { main } from "@/lib/Server";
import { Effect } from "effect";
export async function POST(request: Request): Promise<Response> {
  return main(request).pipe(Effect.runPromise);
}Perform request on the client
The very last step is to implement the component to send the request to the API.
Before doing that we need to create a new Effect. The user provides an email (string) and the Effect is responsible to make the API request:
export const main = (email: string) =>
  Effect.gen(function* (_) {
    // TODO
  });The implementation is similar to before:
Configto collect the endpoint of the APIHttpClientto define the request, passing theemailin the bodyHttp.client.fetch()to perform the request
import * as AppSchema from "@/lib/Schema";
import * as Http from "@effect/platform/HttpClient";
export const main = (email: string) =>
  Effect.gen(function* (_) {
    const apiUrl = yield* _(Effect.config(Config.string("SUBSCRIBE_API")));
    const req = yield* _(
      Http.request.post(apiUrl),
      Http.request.acceptJson,
      Http.request.schemaBody(AppSchema.RouteRequest)({ email })
    );
    return yield* _(
      req,
      Http.client.fetch(),
      Effect.flatMap(Http.response.schemaBodyJson(AppSchema.SubscribeResponse))
    );
  });page component
We can then call main inside a react component, passing the email provided by the user:
"use client"
import { useState } from "react";
export default function Page() {
  const [email, setEmail] = useState("");
  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // TODO
  };
  return (
    <form onSubmit={onSubmit}>
      <input
        type="email"
        name="email"
        id="email"
        placeholder="Email address"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Subscribe</button>
    </form>
  );
}Inside onSubmit we call main using runPromiseExit, which returns Exit.
Exitis similar toEither: it has either aFailureorSuccessvalue.type Exit<E, A> = Failure<E, A> | Success<E, A>
Using Exit.match we can provide a response to the user for both success and failure:
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  (await Client.main(email).pipe(Effect.runPromiseExit)).pipe(
    Exit.match({
      onFailure: (cause) => {
        console.error(cause);
      },
      onSuccess: (subscriber) => {
        console.log(subscriber);
      },
    })
  );
};ConvertKit form id
Inside Config we specify the ConvertKit form id (CONVERTKIT_FORM_ID).
Every subscriber on ConvertKit is linked to a form. A form id is therefore required to subscribe a new email.
You need to create a form inside the "Landing Pages & Forms" section:
Create and open the page to edit the form, then click on the "Publish" button. You can find the id of the form you just created inside the popup:
This is it!
Open the page, add your email, and sign up! All powered by ConvertKit and Effect!
How do we make sure it all works as expected? Testing!
Turns out testing becomes easy and natural when using Effect. We can inject custom Config values for testing and we can use Mock Service Worker to mock HTTP requests.
👉 You can read the next article on how to use
vitestandmswto test and Effect app
If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below 👇
Thanks for reading.
