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.tryruns the Promise;catchmaps the rejection into a typed error (RecorderDenied) instead of letting an untypedunknownescape. (Never useEffect.promiseon anything that can reject — it turns a rejection into an unrecoverable defect.)Effect.fail(error)is a normal, recoverable failure that shows up in theEchannel.Effect.voidis the do-nothing success.Effect.suspend(() => ...)defers the check until the Effect actually runs, so thetypeofguard 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:
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>).makeis an Effect that builds the implementation — here it delegates to Drizzle'smakeWithDefaults().static readonly layer— the always-present handle used to provide the service.Layer.effect(this, this.make)says "to build me, run mymake".thisrefers to the class itself (the tag),this.maketo the builder you declared above.- Consuming it — anywhere inside an
Effect.gen, youyield*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 onerror._tagand the compiler narrows the fields — noinstanceof, no guessing shapes. Schema.TaggedErrorClass(not plainError) 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 whyRpcErroris aSchema.Unionof all of them — one schema the client uses to reconstruct whatever the server failed with.- You never
throwthese. Youyield* Effect.fail(new NotFound({ ... }))(or justyield* new NotFound({ ... }), since tagged errors are yieldable), and the failure lands in theEchannel 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.Arraydescribe the exact shape you expect.Schema.decodeUnknownEffect(schema)(value)takes a value typedunknownand returns anEffectthat either succeeds with the fully-typed value or fails with aSchemaError. Theunknownstops 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 aRedactedso 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 mysteriousundefineddeep in a query.Config.succeed(value)lifts a plain constant into the sameConfigshape, 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.currentTimeMillisgives you the epoch millis, but from theClockservice in context. In production that's the real clock; in a test you provideTestClock.layer()and drive time by hand withTestClock.adjust("1 hour"). The eviction logic that depends onnowbecomes 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), neverMath.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, everybestMovecall runs to completion before the next starts, so they can't clobber each other'slistener.
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.
Your monorepo setup
How this monorepo is wired — pnpm workspaces and the version catalog, the Turborepo task graph, @repo/* packages, and the local quality gates.
The Effect RPC API layer
How the Effect v4 RPC surface replaced tRPC — schema groups, Effect handlers, the router layer, the @repo/api client, and how to add a new RPC.