TypeScript · Effect v3

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.

The one-sentence pitch: Effect does for the runtime behavior of your program what TypeScript did for its data — makes the invisible checkable.

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:

ConstructorUse forType produced
Effect.succeed(x)a pure valueEffect<X>
Effect.fail(e)a known failureEffect<never, E>
Effect.sync(f)sync side-effect that can't throwEffect<A>
Effect.try(f)sync code that may throwEffect<A, UnknownException>
Effect.promise(f)a Promise that won't rejectEffect<A>
Effect.tryPromise(f)a Promise that may rejectEffect<A, UnknownException>

And the runners, used once, at your program's entry point: Effect.runSync, Effect.runPromise, Effect.runFork (fire-and-forget fiber).

Mental shift #1: in Promise-land, creation is execution. In Effect, creation is just data. This is what makes retry/timeout/racing composable — you can pass a computation around, wrap it, and duplicate it before anything happens.

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 DbLive for DbTest.
  • 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"))
Compare with Promises: 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

PackageWhat it gives you
effectCore: Effect, Schema, Stream, STM, Schedule, Layer, structured logging/metrics/tracing (OpenTelemetry-ready)
@effect/platformCross-runtime HTTP server & client, file system, workers — Node/Bun/Deno/browser
@effect/sqlSQL clients (Postgres, SQLite, MySQL…) with migrations, as services
@effect/aiProvider-agnostic LLM calls (Anthropic/OpenAI/…) with typed tools, retries, streaming — why AI startups keep adopting Effect
@effect/cluster + workflowsDurable execution, sharded actors — the Temporal-shaped ambition, in-language
effect-smolThe 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).
The real cost is not the API — it's the review culture. 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.