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:
nextjs
is 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@latest
Note: 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
pnpm
from 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/platform
Folder 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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
Starting point: Services in Effect
The first step in all Effect projects is defining a Service.
A Service in Effect is a Typescript
interface
defining 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.all
collects all the configuration values (in this case 3 strings defined usingConfig.string
)Config.map
extracts 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_key
email
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
:POST
request at the given URLrequest.setHeaders
: Define headers for the requestrequest.schemaBody
: Provide the body of thePOST
request from aSchema
(SubscribeRequest
)
HttpClient
allows 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()
usesfetch
to send the given requestresponse.schemaBodyJson
validates 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 π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
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.catchTags
allows to handle specific errors from their_tag
Effect.catchAll
allows 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:
Config
to collect the endpoint of the APIHttpClient
to define the request, passing theemail
in 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
.
Exit
is similar toEither
: it has either aFailure
orSuccess
value.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 a form to subscribe a new user in your ConvertKit account
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:
You can find the form id inside the "Publish" 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
vitest
andmsw
to 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.