In the previous chapter, I said that Effect Service is a system that automatically makes dependencies available to any part of your program that needs them, no matter how deep in the call chain, and tracks all of this at the type level so the compiler tells us if we forgot to provide something.
Let’s see it in action. Let’s create a service named Email.
In Effect, services are designed in 2 steps: definition and implementation.
Definition
The definition is where you describe the shape of a service. The shape is the set of operations the service exposes, written purely as types.
9 collapsed lines
import { Context, Effect } from "effect";
class SendPasswordResetEmailError extends Error { constructor(cause: unknown) { super("Failed to send the password reset email"); this.name = "SendPasswordResetEmailError"; this.cause = cause; }}
// Define serviceexport class Email extends Context.Service< Email, { readonly sendPasswordResetEmail: ( to: string, resetLink: string, ) => Effect.Effect<void, SendPasswordResetEmailError>; }>()("app/Email") {}Let’s understand what’s going on here.
You are creating a class named Email that extends Context.Service.
The first type parameter, Email, is the unique identifier of the service at the type level. It has to be the class you are defining, which is why we write Email here, matching the class name.
The second type parameter is an object describing the service’s shape. In our case, the shape says: “this service has one method named sendPasswordResetEmail. It takes a recipient address and a reset link, and returns an Effect that produces nothing on success and can fail with a SendPasswordResetEmailError.”
After the type parameters, there is an empty pair of parentheses, and then a second call that takes a string: "app/Email". That string is the key. Effect uses it internally as the label under which it stores the implementation inside a Context (an object that maps keys to their implementations). Two services with the same shape but different keys are different services. The convention is to give the key a path-like prefix that reflects where the service lives in your codebase, like app/Email or myapp/db/Database, so it stays unique across the project.
At this point, the Email service can’t actually send an email yet, because we have only described what the
service does, not how it does it. That is the job of the implementation step, which we’ll look at next.
Implementation
The implementation is where you tell Effect how the service actually works. Here is the SES implementation of our
Email service:
21 collapsed lines
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";import { Context, Effect, Layer } from "effect";
class SendPasswordResetEmailError extends Error { constructor(cause: unknown) { super("Failed to send the password reset email"); this.name = "SendPasswordResetEmailError"; this.cause = cause; }}
// Define serviceexport class Email extends Context.Service< Email, { readonly sendPasswordReset: ( to: string, resetLink: string, ) => Effect.Effect<void, SendPasswordResetEmailError>; }>()("app/Email") {}
// Implement serviceexport const EmailLayer = Layer.effect( Email, Effect.gen(function* () { const sesClient = new SESClient({ region: "us-east-1", credentials: { accessKeyId: "your-access-key-id", secretAccessKey: "your-secret-access-key", }, });
function sendPasswordResetEmail(to: string, resetLink: string) { return Effect.tryPromise({ try: () => sesClient.send( new SendEmailCommand({ Destination: { ToAddresses: [to] }, Message: { Body: { Html: { Charset: "UTF-8", Data: ` <h1>Password Reset</h1> <p>Click the link below to reset your password:</p> <a href="${resetLink}">Reset Password</a> `, }, }, Subject: { Charset: "UTF-8", Data: "Reset Your Password" }, }, Source: "noreply@yourapp.com", }), ), catch: (cause) => new SendPasswordResetEmailError(cause), }).pipe(Effect.asVoid); }
return Email.of({ sendPasswordResetEmail }); }),);The implementation is a Layer, which we define using Layer.effect(). Before I explain the arguments passed to Layer.effect, let me first explain what a Layer is.
A Layer is a recipe for building the implementation of a service. It says: “to build the Email implementation, here is the Effect you need to run. When that Effect succeeds, it produces an object that satisfies the Email shape, and Effect will store it in the Context under the right key.”
Now that you understand what a Layer is, let’s look at the arguments passed to Layer.effect.
The first argument is the service. We pass Email, the same class we used in the definition. This tells the Layer which service it is building an implementation for. When the Layer runs, the result is stored in the Context under the "app/Email" key.
The second argument is an Effect that, when run, produces a value matching the Email shape. Inside that Effect, we first create an SES client and configure it with the AWS region and credentials. Next, we define a sendPasswordReset function that wraps the SES call in Effect.tryPromise, so that any thrown error is captured into the SendPasswordResetEmailError. Finally, we return an object containing that function, wrapped in Email.of.
What does Email.of() do? At runtime, it does nothing. It just returns the object you pass in. Its job is purely to help TypeScript. It forces the returned object to match the Email shape exactly. As a result, if you forget a method or get a signature wrong, the compiler catches it right here at the implementation site.