albertonline.org docs
Learn

Learn Effect v4

Effect v4 taught from this repo's real code — Effects vs Promises, services, layers, typed errors, Schema, Config, and concurrency.

This repo runs on Effect v4 (beta). The point of this page is to teach Effect by reading code you already own: every snippet below is copied from a real file in this monorepo, cited by path, so you learn the framework and your own codebase at the same time.

The canonical reference is the vendored tools/effect-smol git subtree — it is pinned to the exact effect@4.0.0-beta release this repo uses. Trust it over blog posts and Stack Overflow, which describe v3 and will steer you wrong on renamed APIs.

1. What is an Effect (and why not a Promise)?

A Promise<A> tells you one thing: it will eventually produce an A — or reject with something the type system knows nothing about. An Effect.Effect<A, E, R> tells you three:

  • A — the success value,
  • E — the typed error channel (every way it can fail, in the type),
  • R — the requirements: the services it needs to run.

And unlike a Promise, an Effect is a description — nothing happens until you run it. That's what makes errors, dependencies, retries, and cancellation composable instead of ad-hoc.

Look at how RecorderService wraps a browser API that can reject (packages/cvm-services/src/recorder-service.ts):

const askPermission = Effect.tryPromise({
  try: () => globalThis.navigator.mediaDevices.getUserMedia({ video: true, audio: true }),
  catch: (cause) =>
    new CvmError.RecorderDenied({
      reason: cause instanceof Error ? cause.message : String(cause),
    }),
});

const ensureSupport = Effect.suspend(() => {
  if (typeof globalThis.MediaRecorder === "undefined") {
    return Effect.fail(new CvmError.RecorderUnavailable({ reason: "MediaRecorder not supported" }));
  }
  return Effect.void;
});

Read it line by line:

  • Effect.tryPromise({ try, catch }) is the only correct way to bring a rejecting Promise into Effect. try runs the Promise; catch maps the rejection into a typed error (RecorderDenied) instead of letting an untyped unknown escape. (Never use Effect.promise on anything that can reject — it turns a rejection into an unrecoverable defect.)
  • Effect.fail(error) is a normal, recoverable failure that shows up in the E channel. Effect.void is the do-nothing success.
  • Effect.suspend(() => ...) defers the check until the Effect actually runs, so the typeof guard reads the environment at run time, not at definition time.

Now the pieces get composed with Effect.gen — Effect's version of async/await:

Effect.gen(function* () {
  yield* ensureSupport;
  const stream = yield* askPermission;
  const mimeType = opts?.mimeType ?? MIME;
  // ...
});

yield* is to Effect what await is to Promises: it runs the yielded Effect and unwraps its success value. The difference is that the errors of ensureSupport and askPermission are collected into the return type automatically. The method's signature spells the union out:

start: (
  _videoId: CvmTypes.Video["id"],
  opts?: { readonly mimeType?: string }
): Effect.Effect<RecorderHandle, CvmError.RecorderDenied | CvmError.RecorderUnavailable> =>

A caller of start can see, in the type, exactly what can go wrong — RecorderDenied or RecorderUnavailable — and the compiler forces them to handle both. That's the whole pitch: failures are values in the type, not surprises at runtime.

2. Services & layers — the house shape

A service is a bundle of capabilities (an interface + its implementation). A layer is the recipe for building that service, including wiring up whatever it depends on. This repo has exactly one shape for both — learn it once and every service reads the same.

Here is the smallest complete example, the database service (packages/db/src/effect/layer.ts):

export class PgDrizzle extends Context.Service<PgDrizzle>()("@repo/db/PgDrizzle", {
  make: makeWithDefaults(),
}) {
  static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(PgLive));
}

Three parts, and every service in the repo has exactly these three:

  1. Context.Service<PgDrizzle>()("@repo/db/PgDrizzle", { make }) — declares the service. The string "@scope/Name" is its unique identity in the dependency context (always @repo/<pkg>/<Name>). make is an Effect that builds the implementation — here it delegates to Drizzle's makeWithDefaults().
  2. static readonly layer — the always-present handle used to provide the service. Layer.effect(this, this.make) says "to build me, run my make". this refers to the class itself (the tag), this.make to the builder you declared above.
  3. Consuming it — anywhere inside an Effect.gen, you yield* the class to get the built service. The JSDoc in that same file shows it:
Effect.gen(function* () {
  const db = yield* PgDrizzle;
  return yield* db.select().from(todo).where(eq(todo.id, id)).limit(1);
});

When make needs to do real setup work, it's an Effect.gen that can pull in other services first. VideoProcessingService (apps/cvm/app/services/video-processing-service.ts) opens by acquiring its dependencies:

make: Effect.gen(function* () {
  const effectFs = yield* FileSystem.FileSystem;
  const ffmpegCommands = yield* FFmpegCommandsService;
  const transcriptionSemaphore = yield* Semaphore.make(TRANSCRIPTION_PERMITS);
  // ...
});

Those yield*s are how a service declares "I need a filesystem and an ffmpeg service to exist." That requirement then has to be satisfied when you build the layer — which is what Layer.provide does:

static readonly layer = Layer.effect(this, this.make).pipe(
  Layer.provide(FFmpegCommandsService.layer),
  Layer.provide(NodeServices.layer)
);

Composition order is inner-to-outer. A layer's make yields the services it needs; those must be Layer.provided from the inside out. PgDrizzle provides PgLive to itself the same way. Layer.mergeAll does not cross-wire siblings — use it only for independent layers whose dependencies are already satisfied at the same level.

Why bother with layers instead of just new-ing things? Because the wiring is now a value you can swap: a test provides a fake PgLive, production provides the real pooler — the service code never changes.

3. Typed errors — Schema.TaggedErrorClass, never throw

Section 1 showed failures flowing through the E channel. Here is how you define those failures. The RPC error set (packages/api/src/rpc/errors.ts):

import { Schema } from "effect";

export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()("Unauthorized", {
  message: Schema.String,
}) {}

export class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
  resource: Schema.String,
  id: Schema.Union([Schema.String, Schema.Number]),
}) {}

export const RpcError = Schema.Union([Unauthorized, Forbidden, NotFound, Internal]);
export type RpcError = Schema.Schema.Type<typeof RpcError>;
  • Each error is a class with a _tag (the string you pass) and typed fields (message, resource, id). Because they're tagged, you discriminate on error._tag and the compiler narrows the fields — no instanceof, no guessing shapes.
  • Schema.TaggedErrorClass (not plain Error) means each error is also a Schema: it can be encoded to JSON, sent across the RPC wire, and decoded on the other side back into the same class. That's why RpcError is a Schema.Union of all of them — one schema the client uses to reconstruct whatever the server failed with.
  • You never throw these. You yield* Effect.fail(new NotFound({ ... })) (or just yield* new NotFound({ ... }), since tagged errors are yieldable), and the failure lands in the E channel where callers can see and handle it.

Two flavours, one rule. Use Schema.TaggedErrorClass when the error crosses a boundary and must be serialized (RPC responses). Use Data.TaggedError for errors that only ever live in memory — e.g. VideoProcessingService's CouldNotTranscribeError. Both give you the same _tag discrimination; only the Schema variant is wire-safe. Throwing a raw Error out of an Effect is the thing to never do — it becomes an untyped defect and disappears from the type.

4. Schema at the boundaries — decode, never cast

The house rule is blunt: no as casts anywhere except as const. If you have a value of unknown shape, you don't assert what it is — you decode it through a Schema, and the decoded type is what the rest of the code is allowed to see. Anything that fails the shape becomes a typed error, not a landmine three functions downstream.

VideoProcessingService does exactly this after collecting transcription results from an external API:

const transcribeClipsSchema = Schema.Array(
  Schema.Struct({
    id: Schema.String,
    words: Schema.Array(
      Schema.Struct({ start: Schema.Number, end: Schema.Number, text: Schema.String })
    ),
    segments: Schema.Array(
      Schema.Struct({ start: Schema.Number, end: Schema.Number, text: Schema.String })
    ),
  })
);

// ...at the end of transcribeClips:
return yield * Schema.decodeUnknownEffect(transcribeClipsSchema)(results);
  • Schema.Struct / Schema.Array describe the exact shape you expect.
  • Schema.decodeUnknownEffect(schema)(value) takes a value typed unknown and returns an Effect that either succeeds with the fully-typed value or fails with a SchemaError. The unknown stops here — everything after this line is strongly typed and proven to match.
  • This is the alternative to as: instead of promising the compiler "trust me, it's this shape", you check at the exact place data enters the system (HTTP body, query params, env vars, DB rows, RPC payloads, a third-party API response like this one).

unknown is allowed at only two spots: the immediate argument of a decodeUnknown* / JSON.parse boundary, and the cause of a caught error. Everywhere else, narrow it with a Schema before you propagate it. Reaching for as Foo almost always means a missing Schema.decode — fix the source of the unknown shape, don't cast around it.

5. Config — read env through Config, not process.env

Environment variables are just another untrusted boundary, so Effect gives them the same treatment: read them through the Config module, where a missing value is a typed error and a secret can be redacted. Service code never touches process.env directly.

The DB layer reads the connection string (packages/db/src/effect/layer.ts):

export const PgLive = PgClient.layerConfig({
  url: Config.redacted("DATABASE_URL"),
  idleTimeout: Config.succeed(PG_POOL_RESILIENCE.idleTimeout),
  connectionTTL: Config.succeed(PG_POOL_RESILIENCE.connectionTTL),
  connectTimeout: Config.succeed(PG_POOL_RESILIENCE.connectTimeout),
  maxConnections: Config.succeed(PG_POOL_RESILIENCE.maxConnections),
});
  • Config.redacted("DATABASE_URL") reads the var and wraps it in a Redacted so it can never be accidentally logged — the connection string is a secret. If the var is absent, the layer fails loudly at startup with a typed config error, not a mysterious undefined deep in a query.
  • Config.succeed(value) lifts a plain constant into the same Config shape, so the pool tuning constants and the env-read URL compose uniformly.

For non-secret values with a fallback, VideoProcessingService shows the default pattern:

const obsDir =
  yield *
  Config.string("OBS_RECORDING_DIR").pipe(Config.withDefault(path.join(homedir(), "Videos")));

Config.string(name) reads a string var; .pipe(Config.withDefault(...)) supplies a fallback so the var is optional. Same idea as redacted, minus the secrecy.

Keep process.env out of service code entirely. If you must read it raw, do it only in a thin entry-point file. In services, Config is the boundary — it gives you validation, redaction, and testability (a test can provide a ConfigProvider instead of mutating the real environment).

6. Determinism — Clock and Random, not Date.now() / Math.random()

The wall clock and the RNG are non-deterministic side effects: they read hidden global state and give a different answer every time. That makes any code using them impossible to test reproducibly. Effect's fix is to route both through injectable default services — and this repo enforces it with the effect/no-date and effect/no-math-random lint rules.

SignalBus reads the current time the correct way (packages/realtime/src/signal-bus.ts):

const publish = (signal: Signal) =>
  Effect.gen(function* () {
    // Read time through the Effect Clock (not Date.now) so eviction order
    // is deterministic and injectable — tests drive it with TestClock.
    const now = yield* Clock.currentTimeMillis;
    // ...
  });
  • yield* Clock.currentTimeMillis gives you the epoch millis, but from the Clock service in context. In production that's the real clock; in a test you provide TestClock.layer() and drive time by hand with TestClock.adjust("1 hour"). The eviction logic that depends on now becomes fully deterministic.
  • For randomness inside Effect, the parallel is yield* Random.next / Random.nextIntBetween(a, b), seedable in tests with .pipe(Random.withSeed("seed")). Outside Effect, use @repo/utils' secureId() / randomInt() (crypto-backed), never Math.random().

Note Math.sqrt, Math.max, Math.min are fine — they're pure. The banned ones are the non-deterministic reads: Date.now(), new Date(), Math.random(). At a true edge (a React render, an SSR route handler) where no Clock is in scope, Date/crypto are allowed with a justified // oxlint-disable-next-line effect/no-date — but prefer threading a Clock-read timestamp in from an effectful caller.

7. Concurrency — protect shared state with a semaphore

The last piece: when a service holds mutable state and is called concurrently, you can't rely on ad-hoc booleans. Effect gives you TxSemaphore / TxRef for this. EngineService wraps a single Stockfish engine that has one shared listener, so two concurrent searches would race — the fix is a 1-permit semaphore that serializes them (packages/chess-services/src/engine-service.ts):

make: Effect.gen(function* () {
  const engine = yield* Effect.tryPromise(() => initEngine("lite-single"));
  const lock = yield* TxSemaphore.make(1);
  // ...
  const bestMove = (fen: string, options?: { movetimeMs?: number; skill?: number }) =>
    TxSemaphore.withPermit(
      lock,
      Effect.gen(function* () {
        // ...only one fiber is inside here at a time
      })
    );
  return { bestMove } as const;
}),
  • TxSemaphore.make(1) creates a semaphore with one permit — a mutex.
  • TxSemaphore.withPermit(lock, effect) acquires a permit, runs the effect, and releases the permit even on interruption. With one permit, every bestMove call runs to completion before the next starts, so they can't clobber each other's listener.

The same primitive, sized differently, bounds concurrency against an external service. VideoProcessingService caps in-flight Whisper API calls:

const transcriptionSemaphore = yield * Semaphore.make(TRANSCRIPTION_PERMITS); // 20

const response =
  yield *
  transcriptionSemaphore.withPermits(1)(Effect.tryPromise({/* OpenAI transcription call */}));

Here transcribeClips fans out over all clips with { concurrency: "unbounded" }, but each transcription must take one of the 20 permits — so at most 20 requests hit OpenAI at once, no matter how many clips there are. The semaphore is the throttle.

Reach for TxSemaphore (1 permit) to serialize access to shared mutable state a service owns, and Semaphore.make(n) to cap how many operations run at once against a downstream. Don't hand-roll locks with mutable booleans — a semaphore releases correctly even when a fiber is interrupted.

Putting it together

Every section above is one facet of the same idea: make the implicit explicit and let the type system carry it. Errors, dependencies, env vars, time, randomness, and concurrency all become values you compose — an Effect, a Layer, a Schema, a Config, a Clock, a semaphore — instead of side effects you hope go right. When you add a service, copy the Context.Service + static layer shape; when you cross a boundary, decode; when you can fail, return a tagged error; when you need the clock, ask for it.

See also: How this monorepo is wired and The Effect RPC API layer.

On this page