Skip to content
Published on

Effect-TS Deep Dive — Solving Effects, DI, Resources, and Concurrency in One TypeScript Library

Authors

Prologue — The Moment Promise Stops Being Enough

Look at the code below. It is the shape most TypeScript apps still ship.

async function chargeCustomer(userId: string, amount: number): Promise<Receipt> {
  const user = await db.getUser(userId)
  const card = await stripe.getDefaultCard(user.stripeId)
  const charge = await stripe.charge(card, amount)
  await db.saveReceipt(charge)
  await mailer.sendReceipt(user.email, charge)
  return toReceipt(charge)
}

Six lines, five awaits. It works — on the happy path. But the signature of this function tells you only two things.

  1. It returns a Promise.
  2. On success, you get a Receipt.

What it does not say:

  • Which errors can be thrown — lumped under Error.
  • Which dependencies it requires — db, stripe, mailer are sucked in via closure.
  • Whether a mid-flight failure leaves the customer charged but un-emailed.
  • How cancellation propagates (where is AbortSignal?).
  • What happens when stages are parallelized.

That is why refactors are scary. The signature is too quiet. An effect system is the approach that fills the silence with types — Haskell's IO, Scala's ZIO, F# computation expressions, Rust's Result plus explicit ownership: same family.

In TypeScript, the library that claims that seat is Effect-TS. Stable on 3.x in 2026, with fp-ts author Giulio Canti having joined the team and moved fp-ts into maintenance. This piece dissects Effect-TS — Effect<R, E, A>, the operations, the error model, DI, resources, schemas, streams, fibers — and is honest about when not to use it.


1. The Effect Type — Context, Error, and Value Tracked Together

Everything starts with one type.

type Effect<A, E, R>

Three parameters, each non-trivial.

SlotNameMeaning
ASuccessThe value produced on success
EError / FailureThe set of anticipated failures (a union)
RRequirements (Context)Services that must be in the environment to run

Read it as: "An Effect runs in environment R, fails with E, or succeeds with A." Compare to Promise:

// Promise — only the success type shows
function foo(): Promise<Receipt>

// Effect — environment, failure, and success are all in the signature
function foo(): Effect.Effect<Receipt, StripeError | DbError, DbService | StripeService | Mailer>

This looks cosmetic but changes the refactor calculus. When a function starts throwing a new error, the type breaks. When it starts needing a new service, the compiler points at every call site. Promises swallow both silently.

The core idea of effect tracking: "What this function can do" is etched into its type. Callers see, at the signature, what may fail and what is required.

Effect<A, E, R> is a description. It does not run on creation; it is a recipe that, when executed in a given environment, produces the described behaviour. You run it once at the edge with Effect.runPromise. This lazy model is what makes concurrency, retry, and cancellation composable.


2. Core Operations — map, flatMap, zip, race, all

Effect is a monad. Two operations form the base:

  • Effect.succeed(a) — lift A into an effect.
  • Effect.flatMap(eff, a => effB) — chain on the success value.

Every sequencing pattern follows from those two. But writing flatMap everywhere is tiring. Effect-TS provides Effect.gen, a generator-based coroutine notation — looks like async/await, sequences effects.

import { Effect } from "effect"

const program = Effect.gen(function* () {
  const user = yield* getUser(userId)
  const card = yield* getDefaultCard(user.stripeId)
  const charge = yield* chargeCard(card, amount)
  yield* saveReceipt(charge)
  yield* sendReceipt(user.email, charge)
  return toReceipt(charge)
})

yield* resembles await, but the type system carries more. Each yield* accumulates R and E, so the final signature is the auto-merged union of every dependency and every possible failure.

Operations worth knowing

  • Effect.map(eff, fn) — transform the success.
  • Effect.flatMap(eff, fn) — chain on the success.
  • Effect.zip(a, b) — both must succeed; result is a tuple.
  • Effect.all([a, b, c]) — combine many. Options for concurrency, fail-fast vs collect.
  • Effect.race(a, b) — first to finish wins. The other is auto-cancelled.
  • Effect.either(eff) — fold failure into a value (Either).
  • Effect.catchAll(eff, e => recover) — recover from failure.
  • Effect.timeout(eff, "5 seconds") — bound time.
  • Effect.retry(eff, schedule) — retry with a Schedule (back-off, jitter, etc.).

Effect.all is particularly strong:

Effect.all([fetchA, fetchB, fetchC], { concurrency: "unbounded" })
Effect.all(tasks, { concurrency: 8, mode: "either" })

mode: "either" collects results even when some fail — like Promise.allSettled but with proper types and a library-enforced concurrency cap.


3. Errors — Failures and Defects Are Not the Same

The expensive lesson of any effect system: not all failures are alike.

Effect makes two categories explicit.

CategoryMeaningIn the typeExamples
FailureAnticipated, domain-shapedShows in EUserNotFound, PaymentDeclined, RateLimited
DefectAn unexpected bugNot in Enull dereference, infinite loops, dead system

Domain failures live in the type and are recovered from. Defects are bugs — let them propagate; catching them hides real problems. If you blur this line, a single try/catch can swallow your real bugs.

Defining domain errors

import { Data } from "effect"

class UserNotFound extends Data.TaggedError("UserNotFound")<{
  userId: string
}> {}

class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{
  reason: string
  code: number
}> {}

Data.TaggedError builds discriminated-union-friendly errors with a _tag. You can narrow them with switch-like helpers.

const recovered = program.pipe(
  Effect.catchTags({
    UserNotFound: (e) => Effect.succeed(guestReceipt),
    PaymentDeclined: (e) => logAndNotify(e),
  }),
)

The killer feature: when catchTags handles only some cases, the remaining errors stay in the signature. Leaving a case unhandled is visible at the type level. try/catch has no such guarantee.

Promise vs Effect — same task

Promise:

async function charge() {
  try {
    const u = await getUser(id)
    return await stripe.charge(u, amt)
  } catch (e) {
    // e is `unknown`. Which error? You don't know.
    throw e
  }
}

Effect:

const charge = Effect.gen(function* () {
  const u = yield* getUser(id)            // E: UserNotFound
  return yield* stripeCharge(u, amt)      // E: StripeError
})
// Inferred: Effect<Receipt, UserNotFound | StripeError, R>

When recovering, the compiler tells you exactly which errors you're handling. Add a new failure and an unhandled case shows up immediately.


4. Dependency Injection — Context.Tag and Layer

The R slot of Effect<A, E, R> earns its keep here. Effect's DI is not a runtime container; it is the environment, expressed in types.

import { Context, Effect, Layer } from "effect"

// 1. Declare a service by Tag
class DbService extends Context.Tag("DbService")<
  DbService,
  {
    readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
    readonly saveReceipt: (r: Charge) => Effect.Effect<void, DbError>
  }
>() {}

// 2. Use it — yield* the Tag
const fetchUser = (id: string) =>
  Effect.gen(function* () {
    const db = yield* DbService
    return yield* db.getUser(id)
  })
// Inferred: Effect<User, UserNotFound, DbService>

Wherever you reach for DbService, it joins R in the inferred type. The compiler tracks "this effect requires DbService in the environment."

Layer — building the environment

Layer packages service implementations into an environment.

const DbLive = Layer.succeed(DbService, {
  getUser: (id) => /* real impl */,
  saveReceipt: (c) => /* real impl */,
})

const DbTest = Layer.succeed(DbService, {
  getUser: (id) => Effect.succeed(testUser),
  saveReceipt: () => Effect.void,
})

const program = fetchUser("u1").pipe(Effect.provide(DbLive))
//                                                  ^
//                            For tests, swap in DbTest. That's it.

Multiple layers compose via Layer.merge / Layer.provide. Inter-service dependencies are typed: if DbService needs Config, a layer that omits Config is a compile error.

This is not a DI container. No runtime magic — it is functions and closures, but the environment travels in the types. You gain the diagnostic value of a container without dynamic dispatch overhead.


5. Resource Management — Scope, acquireRelease, and using

Leak patterns are familiar. You open a file, something throws midway, the close never runs.

// Promise — leaks waiting to happen
async function processFile() {
  const f = await openFile(path)
  const data = await parse(f) // throws?
  await closeFile(f)          // never reached
}

finally papers over it for simple cases. Once you have multiple resources and concurrency, the hand-coded approach falls apart. Effect solves this with Scope.

acquireRelease

const file = Effect.acquireRelease(
  openFile(path),                          // acquire
  (f) => Effect.promise(() => f.close())   // release (guaranteed)
)

const program = Effect.gen(function* () {
  const f = yield* Effect.scoped(file)  // life-cycle bound to the scope
  return yield* parse(f)
})

Effect.scoped opens a block. When the block ends — by success, failure, or interruption — release runs. Register several resources in one scope and they unwind in reverse order safely.

using — meeting TC39 explicit resource management

ECMAScript's using declaration (Stage 3) is the same idea at the language level. Effect's library-level semantics line up cleanly; when using ships everywhere, the two integrate naturally.

Resource safety is the #1 leak source in distributed systems. Effect shines here.


6. Effect Schema — One Definition for Runtime and Compile Time

TypeScript's limit: compile-time safety does not extend to runtime data — JSON, env, query params. zod claimed that seat. In 2026 a strong rival has emerged: Effect Schema (effect/Schema).

import { Schema as S } from "effect"

const User = S.Struct({
  id: S.String,
  email: S.String.pipe(S.pattern(/^[^@]+@[^@]+$/)),
  age: S.Number.pipe(S.between(0, 150)),
  role: S.Literal("admin", "user", "guest"),
})

type User = S.Schema.Type<typeof User>   // typed automatically

const decoded = S.decodeUnknownEither(User)(jsonBlob)
//   ^ Either<User, ParseError>

Differences from zod:

  • Bidirectional — encode and decode fall out of one definition. Non-JSON types like Date are first-class.
  • Effect integrationS.decode(schema)(data) returns Effect<A, ParseError>, ready to compose with other effects.
  • TransformationsS.transform builds "string-to-Date", "trim+lowercase", and other coercions into the schema itself.
  • Branded typesS.brand gives nominal types. UserId and OrderId over the same string are distinguishable.

A transform

const ISODate = S.Date.pipe(S.transform(
  S.String,
  { decode: (s) => new Date(s), encode: (d) => d.toISOString() }
))

Decode at the boundary once, then carry exact types through the interior. Validation stops being scattered glue.


7. Effect Stream — Push and Pull, One Model

Bulk data, back-pressure, infinite streams — pain in Promise/AsyncIterator land. Effect Stream is pull-based at heart but absorbs push sources naturally.

import { Stream } from "effect"

const numbers = Stream.range(1, 1_000_000)

const program = numbers.pipe(
  Stream.map((n) => n * 2),
  Stream.filter((n) => n % 3 === 0),
  Stream.take(100),
  Stream.run(Sink.collectAll),
)

Stream is lazy. Only Stream.run starts the flow. Intermediate stages are throttled by back-pressure — if a downstream can't keep up, upstream pauses.

Common patterns

  • Stream.fromAsyncIterable — absorb an AsyncIterator.
  • Stream.fromQueue — push sources (webhooks, events).
  • Stream.throttle — periodic control.
  • Stream.broadcast — fan out to many consumers.
  • Stream.fromSchedule — time-based emissions.

Node Streams' back-pressure model is hard because who slows down is implicit. Effect Stream makes the decision explicit, in types and semantics.


8. Fibers and Structured Concurrency

Effect's concurrency primitive is the Fiber — light, co-operative, with cancellation that propagates. Not threads. Not Web Workers. Closer to green threads.

const a = Effect.delay(longFetch(...), "100 millis")
const b = longCompute(...)

// Run both, await both
const both = Effect.all([a, b], { concurrency: 2 })

// First wins — the loser is cancelled automatically
const fastest = Effect.race(a, b)

What structured concurrency means

The core promise: child lifetimes cannot exceed the parent's. If race picks a, b is interrupted immediately. Resources acquired via acquireRelease are released at the interruption point. If the parent fiber dies, every child dies with it. "A background task survived and held a resource" simply does not happen.

Cancellation propagation

const stoppable = Effect.gen(function* () {
  yield* Effect.log("starting")
  yield* Effect.sleep("10 seconds")
  yield* Effect.log("done — never prints if cancelled")
})

const fiber = yield* Effect.fork(stoppable)
yield* Effect.sleep("1 second")
yield* Fiber.interrupt(fiber)  // immediate cancellation; cleanup runs

In Promise-land, cancellation is a second-class citizen — AbortSignal covers some of the ground, but support is uneven. In Effect, every effect is interruptible and Scope guarantees cleanup.


9. Interop — Living with Promises, Callbacks, and AbortSignal

Real code talks to Promise-based APIs, callbacks, and SDKs you do not own. Effect treats those boundaries as first-class.

Promise to Effect

const fetchUser = Effect.tryPromise({
  try: () => fetch(`/api/user/${id}`).then(r => r.json()),
  catch: (e) => new FetchError({ cause: e }),
})

Use Effect.promise when failure cannot occur; Effect.tryPromise to map errors into your domain.

Callback to Effect

const readFile = (path: string) =>
  Effect.async<Buffer, NodeJS.ErrnoException>((resume) => {
    fs.readFile(path, (err, data) => {
      if (err) resume(Effect.fail(err))
      else resume(Effect.succeed(data))
    })
  })

Effect.async handles single-shot callbacks; Effect.asyncEffect handles callbacks that need cleanup.

Bridging AbortSignal

const withAbort = (signal: AbortSignal) =>
  Effect.async<Data, FetchError>((resume) => {
    const c = new AbortController()
    signal.addEventListener("abort", () => c.abort())
    fetch(url, { signal: c.signal })
      .then(r => resume(Effect.succeed(r)))
      .catch(e => resume(Effect.fail(new FetchError({ cause: e }))))
  })

Effect-to-Promise is just Effect.runPromise. Effect is never an isolated island — keep the edge Promise/callback-shaped, the interior all-Effect.


10. From fp-ts to Effect-TS — Why the Merger Matters

The Effect-TS story is a small but meaningful event in TypeScript's functional scene.

  • 2017– fp-ts (Giulio Canti) — Haskell/PureScript type classes ported to TypeScript. ADTs, monads, lenses. Academic in flavour, loved nonetheless.
  • 2020– Effect-TS (Michael Arnaldi and others) — a new approach, heavily ZIO-influenced. Single Effect type at the centre, production-shaped from day one.
  • 2023 Giulio Canti joins Effect-TS. fp-ts effectively enters maintenance mode. Effect-TS is the de-facto successor.
  • 2024 Effect 3.0. API stabilises. Package layout consolidates around the effect single-package model.
  • 2026 3.x stable. Schema, Stream, Match, Worker, CLI satellites mature.

fp-ts is not broken — it still works. But for new code, Effect-TS is the obvious choice. The same people, the docs, the tooling, the adoption, and the ecosystem all live there now.

Real adoption

  • Bun — parts of its tooling internals use Effect (visible in public code).
  • Disney Streaming, Vercel team members, and others have spoken at conferences about Effect in production.
  • A growing pile of OSS — Schema as a zod alternative, backend frameworks built on Effect.

When a TypeScript backend grows past a certain size and "manageable complexity" matters, Effect tends to show up.


11. Comparing — Promise, Result, ZIO, Rust Result

Effect is not the only answer. Other tools fit other shapes.

Plain Promise

  • Pros: standard, everyone knows it, fine for small.
  • Cons: error type is unknown; environment and cancellation are not first-class.
  • When: one-function modules, small scripts, library boundaries.

Result / Either (neverthrow, ts-belt, fp-ts Either)

  • Pros: typed errors, lightweight.
  • Cons: concurrency, cancellation, and DI still need other tools.
  • When: you want explicit errors but not the rest. Often a stepping stone toward Effect.

ZIO (Scala)

  • Pros: Effect-TS's spiritual sibling, older ecosystem.
  • Language: Scala only. Effect is essentially ZIO for TypeScript.

Rust Result + lifetimes

  • Pros: compiler-enforced errors, resources, and ownership.
  • Language: different paradigm, distant from JS infra.

F# Computation Expressions / Haskell IO

  • Pros: deeper type systems with end-to-end effect tracking.
  • Language: same seat, other tribes. Effect-TS is the spirit applied to TS.

A one-liner

ScenarioPick
Short script, single modulePlain Promise
Want typed errors and nothing elseResult library
Large backend, refactor safety, DI, concurrency coherenceEffect-TS
Scala ecosystemZIO
Systems programmingRust Result + lifetimes

12. When Effect Is Right — and When It's Too Much

Effect is not free. Learning curve, runtime overhead, team alignment — all costs.

Effect fits when

  • Backend is medium-to-large with a multi-year lifetime.
  • Refactor safety is business-critical (payments, integrity, IAP).
  • Testability matters — DI / Layer reduce unit-test friction.
  • Resource management is complex (files, connections, locks).
  • Concurrency and cancellation come up a lot.
  • The team knows or is willing to learn the FP / effect paradigm.

Don't reach for it when

  • A single CLI, simple script, or prototype is what you ship.
  • Team is junior and the domain is unstable.
  • The code lives entirely inside React components — use it at the edge, but rolling it everywhere is a burden.
  • You are a library author — forcing Effect on consumers is a hard sell.

Signal: "Our code is becoming a quilt of try/catch and closure-captured clients; a single refactor takes a week" — Effect's neighbourhood. "200-line script, runs once a minute via cron" — async/await is fine.


13. Adoption Strategy — Don't Boil the Ocean

The most common adoption failure: try to convert everything at once. You end up with a 10k-line PR, reviewers freeze, the team gets angry.

Gradual rollout

  1. Start at the edge — one module/service. Keep the external boundary Promise; convert only the interior.
  2. Schema first — adopt Effect Schema alone (no DI, no Stream). Validation gets cleaner with low buy-in.
  3. Result stage — first move to Either/Result for typed errors, then upgrade to Effect.
  4. Layer per service — extract core services into layers. Test doubles fall out for free.
  5. New code only — leave legacy alone, write new modules in Effect.

Team learning

  • First 2 weeks: read Effect types fluently.
  • Next 2 weeks: Effect.gen and core operations.
  • Then: error categories, Layer, Scope.
  • Last: Stream, Fiber.

Functional libraries always feel magical at first. Effect dialled most of the magic down by giving readable shapes, but it is still a paradigm shift.


14. Mini Example — The Payment Pipeline Rewritten

Back to the opening snippet, but in Effect.

import { Effect, Layer, Data, Context, Schema as S } from "effect"

class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {}
class PaymentDeclined extends Data.TaggedError("PaymentDeclined")<{ code: number }> {}

class DbService extends Context.Tag("DbService")<DbService, {
  readonly getUser: (id: string) => Effect.Effect<User, UserNotFound>
  readonly saveReceipt: (c: Charge) => Effect.Effect<void>
}>() {}

class StripeService extends Context.Tag("StripeService")<StripeService, {
  readonly getDefaultCard: (id: string) => Effect.Effect<Card>
  readonly charge: (card: Card, amt: number) => Effect.Effect<Charge, PaymentDeclined>
}>() {}

class MailerService extends Context.Tag("MailerService")<MailerService, {
  readonly sendReceipt: (to: string, c: Charge) => Effect.Effect<void>
}>() {}

const chargeCustomer = (userId: string, amount: number) =>
  Effect.gen(function* () {
    const db = yield* DbService
    const stripe = yield* StripeService
    const mailer = yield* MailerService

    const user = yield* db.getUser(userId)
    const card = yield* stripe.getDefaultCard(user.stripeId)
    const charge = yield* stripe.charge(card, amount)
    yield* db.saveReceipt(charge)
    yield* mailer.sendReceipt(user.email, charge)
    return toReceipt(charge)
  })
// Inferred signature:
// Effect<Receipt,
//        UserNotFound | PaymentDeclined,
//        DbService | StripeService | MailerService>

A reader of the signature already knows:

  • What comes back: Receipt.
  • What can fail: UserNotFound or PaymentDeclined — those, and only those.
  • What is required: DbService, StripeService, MailerService.

Testing is a matter of plugging in mock layers. Adding a new failure lights up the compiler. Adding a new dependency changes the signature. That is the value of effect tracking.


Epilogue — Effects Make Types Say More

Effect-TS in one sentence: types say more. A function communicates not only what it returns but what may fail, what it needs, and how it cancels — all in the signature.

Just as async/await did not replace callbacks but layered richer semantics on top, Effect doesn't replace async/await — it sits above with richer semantics. Some of that ends up in the language eventually (using is one step). Effect is the library that gets you there now.

If five years into a backend you can't tell what a function may throw or require without reading the source — that's where Effect earns its keep. If you're writing a 200-line script — it doesn't.

12-item checklist

  1. Are domain errors defined as Data.TaggedError?
  2. Do function signatures honestly show the error union?
  3. Are you not mixing domain failures and defects in try/catch?
  4. Are services split into Context.Tag + Layer?
  5. Are test layers separate from live layers?
  6. Are resources acquired via acquireRelease / scoped?
  7. Does concurrent work have an explicit concurrency cap?
  8. After a race, is loser cleanup verified?
  9. Is external data validated by Effect Schema at one boundary?
  10. Are streams kept lazy until Stream.run?
  11. Do Promise/callback bridges use tryPromise / async?
  12. Is your adoption plan incremental rather than big-bang?

Ten anti-patterns

  1. Collapsing domain failures into a single Error.
  2. Catching defects in try/catch and hiding real bugs.
  3. No DI — importing clients at the module top, blocking tests.
  4. Opening files/connections without acquireRelease.
  5. Omitting concurrency caps in Effect.all, drowning downstream.
  6. Assuming race losers go uncleaned — they don't, but verify your release logic anyway.
  7. Forcing streams to be eager.
  8. Migrating a large codebase in a single PR.
  9. Library authors forcing Effect on consumers.
  10. Reaching for Effect in a 200-line CLI to justify the learning curve.

Next post candidates

Possible follow-ups: Effect Schema deep dive — diffs from zod, transforms, branded types, Back-pressure pipelines with Effect Stream, Bootstrapping a microservice with Effect Layer — config, logging, tracing, observability.

"When types say more, refactors get less scary. Effect is that promise."

— Effect-TS deep dive, fin.


참고 / References