LabHub

Blog

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

한국어English日本語

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:

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:

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.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:

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

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.

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

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

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

ZIO (Scala)

Rust Result + lifetimes

F# Computation Expressions / Haskell IO

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

Don't reach for it when

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

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:

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

Comments

No comments yet.

Sign in to leave a comment