Effect-TS: A Practical Tutorial
Effect is what happens when you decide Promise was a mistake worth fixing: typed errors, built-in dependency injection, structured concurrency, retries, and resource safety — all tracked by the compiler. This tutorial takes you from zero to a production-shaped program.
1Why Effect exists
Look at this ordinary function signature:
async function getUser(id: string): Promise<User>
It is lying to you by omission. It doesn't tell you it can throw NetworkError. It doesn't tell you it reads a config value and needs a database pool. It starts executing the moment you call it, cannot be cancelled, and if you want retries or a timeout you'll be hand-rolling them with setTimeout and prayer.
Effect replaces Promise<A> with a richer type:
Effect<A, E, R>
// │ │ └─ Requirements: services this computation needs (DI, in the type)
// │ └────── Error: every way this can fail (typed, exhaustive)
// └─────────── Success: what it produces
The same function in Effect:
declare const getUser: (id: string) => Effect<User, NetworkError | UserNotFound, Database>
Now the compiler knows — and enforces — three things the Promise version hid: what you get, how it fails, and what it needs. And because an Effect is a lazy description of a computation (nothing runs until you hand it to a runtime), the runtime can do things Promises fundamentally can't: interrupt it, retry it, race it, time it out, trace it.
2Setup
npm install effect
# or: pnpm add effect / bun add effect
That single package contains the core plus Schema, streams, and the standard library. Strictness matters — enable it in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "bundler"
}
}
Optional but genuinely useful: the Effect LSP plugin improves hover types and error messages in VS Code, which flattens the learning curve considerably.
3Your first Effect
Effects are descriptions. You build a value that says what should happen, then explicitly run it at the edge of your program:
import { Effect } from "effect"
// A description of a computation. Nothing has run yet.
const program = Effect.sync(() => {
console.log("Hello from Effect")
return 42
})
// program: Effect<number, never, never> — succeeds with number, can't fail, needs nothing
const result = Effect.runSync(program) // NOW it runs → 42
The constructors you'll use constantly:
| Constructor | Use for | Type produced |
|---|---|---|
Effect.succeed(x) | a pure value | Effect<X> |
Effect.fail(e) | a known failure | Effect<never, E> |
Effect.sync(f) | sync side-effect that can't throw | Effect<A> |
Effect.try(f) | sync code that may throw | Effect<A, UnknownException> |
Effect.promise(f) | a Promise that won't reject | Effect<A> |
Effect.tryPromise(f) | a Promise that may reject | Effect<A, UnknownException> |
And the runners, used once, at your program's entry point: Effect.runSync, Effect.runPromise, Effect.runFork (fire-and-forget fiber).
4Effect.gen — async/await, upgraded
Chaining with .pipe(Effect.flatMap(...)) gets old fast. Effect.gen gives you imperative-looking code using generators, where yield* plays the role of await:
import { Effect } from "effect"
const program = Effect.gen(function* () {
const a = yield* Effect.succeed(10)
const b = yield* Effect.succeed(32)
yield* Effect.log(`sum is ${a + b}`) // structured logging, built in
return a + b
})
The crucial difference from async/await: every yield*'s error and requirement types accumulate into the program's type. If any step can fail with DbError, the whole block's type says so. You cannot forget.
You'll also see pipe everywhere — it's just left-to-right function application, used to attach behavior:
const resilient = program.pipe(
Effect.timeout("2 seconds"),
Effect.retry({ times: 3 })
)
5Typed errors — the killer feature
Define errors as tagged classes. The tag is what lets Effect (and you) discriminate them at compile time:
import { Data, Effect } from "effect"
class NetworkError extends Data.TaggedError("NetworkError")<{ url: string }> {}
class NotFound extends Data.TaggedError("NotFound")<{ id: string }> {}
const fetchUser = (id: string) =>
Effect.gen(function* () {
if (id === "0") return yield* new NotFound({ id })
return { id, name: "Sayantan" }
})
// fetchUser: (id) => Effect<{id, name}, NotFound> ← the error is IN the type
Handling is exhaustive and surgical — catchTag removes the handled error from the type:
const safe = fetchUser("0").pipe(
Effect.catchTag("NotFound", (e) =>
Effect.succeed({ id: e.id, name: "anonymous" })
)
)
// safe: Effect<{id, name}, never> — compiler-verified: nothing left unhandled
Effect also distinguishes failures (expected, typed, in E) from defects (bugs — thrown exceptions, absurd states). Defects don't pollute your error channel; they crash the fiber unless you deliberately trap them with Effect.catchAllDefect. This is the "errors as values, panics as panics" model Rust people will recognize.
6Services & layers — dependency injection in the types
The third type parameter R is Effect's DI system. Declare a service with a Context.Tag; use it anywhere; provide the implementation once, at the edge:
import { Context, Effect, Layer } from "effect"
// 1. Declare the interface
class Db extends Context.Tag("Db")<Db, {
readonly query: (sql: string) => Effect<unknown[], DbError>
}>() {}
// 2. Use it — note R = Db appears automatically
const listUsers = Effect.gen(function* () {
const db = yield* Db
return yield* db.query("select * from users")
})
// listUsers: Effect<unknown[], DbError, Db> ← requirement tracked
// 3. Provide implementations as Layers
const DbLive = Layer.succeed(Db, { query: (sql) => /* real pg client */ ... })
const DbTest = Layer.succeed(Db, { query: () => Effect.succeed([{ id: "1" }]) })
// 4. Wire at the edge. Until R = never, it won't run — the compiler enforces wiring.
Effect.runPromise(listUsers.pipe(Effect.provide(DbLive)))
Why this beats DI containers and module mocking:
- Forgotten dependencies are compile errors, not runtime container exceptions.
- Testing needs no mocking framework — swap
DbLiveforDbTest. - Layers compose and memoize: a layer can depend on other layers (
Layer.provide), and shared dependencies are constructed exactly once, with lifecycle (setup/teardown) managed for you.
7Concurrency & interruption
Effect's concurrency is structured: computations run on lightweight fibers that are owned by their parent. If the parent dies or no longer needs a child, children are interrupted — and interruption runs their cleanup. Leaked "background promises" stop being a thing.
// Sequential by default; opt into bounded parallelism explicitly:
const results = yield* Effect.all(userIds.map(fetchUser), { concurrency: 5 })
// Race two sources; the loser is automatically interrupted (and cleaned up):
const fastest = yield* Effect.race(fromCache, fromOrigin)
// Timeout is just another combinator — it interrupts, not abandons:
const bounded = yield* fetchUser("7").pipe(Effect.timeout("800 millis"))
Promise.race leaves the loser running forever. AbortController is manual, viral plumbing. In Effect, cancellation is a first-class runtime capability — every combinator participates automatically.8Retries & schedules
A Schedule is a first-class, composable description of "when to do something again" — used for both retrying failures and repeating successes:
import { Effect, Schedule } from "effect"
// exponential backoff starting at 100ms, jittered, capped at 5 attempts
const policy = Schedule.exponential("100 millis").pipe(
Schedule.jittered,
Schedule.intersect(Schedule.recurs(5))
)
const robust = flakyRequest.pipe(
Effect.retry(policy),
Effect.timeout("10 seconds")
)
Because policies are values, your team can define standardApiRetry once and reuse it across every call site — try expressing that cleanly with hand-rolled retry loops.
9Resource safety
acquireRelease ties a resource's cleanup to its acquisition. The release runs on success, failure, or interruption — the try/finally you can't get wrong:
const connection = Effect.acquireRelease(
openConnection(), // acquire
(conn) => Effect.sync(() => conn.close()) // release — guaranteed
)
const program = Effect.scoped(
Effect.gen(function* () {
const conn = yield* connection
return yield* conn.query("...")
})
) // leaving the scope closes the connection, no matter how we left
Scopes nest and compose: ten resources acquired in a scope are released in reverse order, even if the fiber is interrupted halfway through acquiring the seventh. This is the machinery Layer uses under the hood for service lifecycles.
10Schema — parse, don't validate
Schema (bundled in the core package) describes data with two-way transformations — decode unknown input into rich domain types, encode back out:
import { Schema } from "effect"
const User = Schema.Struct({
id: Schema.UUID,
name: Schema.NonEmptyString,
createdAt: Schema.Date // decodes "2026-07-12T…" string → real Date object
})
type User = typeof User.Type
const decode = Schema.decodeUnknown(User)
const user = yield* decode(jsonFromTheWire)
// Effect<User, ParseError> — malformed input is a typed failure like any other
Versus Zod: schemas are bidirectional (decode and encode), transformations are first-class (string→Date, cents→BigDecimal), and failures flow through the same typed error channel as everything else. If you're already in Effect, there's no reason to bolt on a second validation library.
11Capstone: a resilient API client
Everything above, in one realistic ~40-line program — typed errors, DI, validation, backoff, timeout, and bounded parallelism:
import { Context, Data, Effect, Layer, Schedule, Schema } from "effect"
class ApiError extends Data.TaggedError("ApiError")<{ status: number }> {}
const Post = Schema.Struct({ id: Schema.Number, title: Schema.String })
// service: the only place that knows about fetch
class Http extends Context.Tag("Http")<Http, {
readonly getJson: (url: string) => Effect<unknown, ApiError>
}>() {}
const HttpLive = Layer.succeed(Http, {
getJson: (url) =>
Effect.tryPromise({
try: () => fetch(url).then((r) => {
if (!r.ok) throw r.status
return r.json()
}),
catch: (s) => new ApiError({ status: Number(s) || 0 })
})
})
const getPost = (id: number) =>
Effect.gen(function* () {
const http = yield* Http
const raw = yield* http.getJson(`https://api.example.com/posts/${id}`)
return yield* Schema.decodeUnknown(Post)(raw)
}).pipe(
Effect.retry(Schedule.exponential("200 millis").pipe(Schedule.intersect(Schedule.recurs(3)))),
Effect.timeout("5 seconds")
)
// fetch 20 posts, max 5 in flight, one line:
const program = Effect.all(
Array.from({ length: 20 }, (_, i) => getPost(i + 1)),
{ concurrency: 5 }
)
Effect.runPromise(program.pipe(Effect.provide(HttpLive)))
.then(console.log)
Count what you got for free: exponential backoff with a cap, a hard timeout that actually cancels, five-way bounded parallelism, schema-validated responses, a swappable HTTP layer for tests, and a return type that admits exactly two failure modes (ApiError | ParseError) plus a possible timeout — all visible to the compiler. The Promise version of this is 150 lines and three subtle bugs.
12The ecosystem
| Package | What it gives you |
|---|---|
effect | Core: Effect, Schema, Stream, STM, Schedule, Layer, structured logging/metrics/tracing (OpenTelemetry-ready) |
@effect/platform | Cross-runtime HTTP server & client, file system, workers — Node/Bun/Deno/browser |
@effect/sql | SQL clients (Postgres, SQLite, MySQL…) with migrations, as services |
@effect/ai | Provider-agnostic LLM calls (Anthropic/OpenAI/…) with typed tools, retries, streaming — why AI startups keep adopting Effect |
@effect/cluster + workflows | Durable execution, sharded actors — the Temporal-shaped ambition, in-language |
effect-smol | The v4 work-in-progress: smaller bundles, faster core |
13Should you adopt it?
Strong yes when…
- You're building long-lived backend services with lots of async orchestration — agents, workflow engines, integration-heavy APIs.
- Failure handling is a product requirement, not an afterthought (payments, infra tooling).
- The team is willing to learn a dialect, and owns the codebase long-term.
Probably no when…
- Glue scripts, CLIs, prototypes — the ceremony outweighs the payoff.
- A team of Effect-skeptics maintains it after you. Effect is viral: once the core is Effect, everything touching it wants to be. Half-in is the worst position.
- Bundle size on a constrained frontend matters more than correctness machinery (v4 aims to shrink this).
Effect.gen code reads fine after a week, but your team must agree to think in "effects as values." Budget 2–3 weeks of reduced velocity for the first Effect service; the payoff compounds after that.14Going further
- effect.website — official docs; the tutorial track is genuinely good
- github.com/Effect-TS/effect — the monorepo
- Effect-TS/examples — realistic app examples
- Effect YouTube — Effect Days talks; the intro workshops are the fastest on-ramp
- Effect Discord — very active, maintainers answer questions
Suggested path: skim sections 1–4 here → do the official quickstart → rebuild one small real thing you already have (a fetch-with-retry script is perfect) → only then read about Layers in anger.