✍️ 필사 모드: Effect-TS Deep Dive — Solving Effects, DI, Resources, and Concurrency in One TypeScript Library
EnglishPrologue — 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.
- It returns a
Promise. - 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,mailerare 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.
| Slot | Name | Meaning |
|---|---|---|
A | Success | The value produced on success |
E | Error / Failure | The set of anticipated failures (a union) |
R | Requirements (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)— liftAinto 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 aSchedule(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.
| Category | Meaning | In the type | Examples |
|---|---|---|---|
| Failure | Anticipated, domain-shaped | Shows in E | UserNotFound, PaymentDeclined, RateLimited |
| Defect | An unexpected bug | Not in E | null 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
Dateare first-class. - Effect integration —
S.decode(schema)(data)returnsEffect<A, ParseError>, ready to compose with other effects. - Transformations —
S.transformbuilds "string-to-Date", "trim+lowercase", and other coercions into the schema itself. - Branded types —
S.brandgives nominal types.UserIdandOrderIdover the samestringare 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 anAsyncIterator.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
Effecttype 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
effectsingle-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
| Scenario | Pick |
|---|---|
| Short script, single module | Plain Promise |
| Want typed errors and nothing else | Result library |
| Large backend, refactor safety, DI, concurrency coherence | Effect-TS |
| Scala ecosystem | ZIO |
| Systems programming | Rust 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
- Start at the edge — one module/service. Keep the external boundary Promise; convert only the interior.
- Schema first — adopt Effect Schema alone (no DI, no Stream). Validation gets cleaner with low buy-in.
- Result stage — first move to
Either/Resultfor typed errors, then upgrade to Effect. - Layer per service — extract core services into layers. Test doubles fall out for free.
- New code only — leave legacy alone, write new modules in Effect.
Team learning
- First 2 weeks: read Effect types fluently.
- Next 2 weeks:
Effect.genand 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:
UserNotFoundorPaymentDeclined— 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
- Are domain errors defined as
Data.TaggedError? - Do function signatures honestly show the error union?
- Are you not mixing domain failures and defects in
try/catch? - Are services split into
Context.Tag+Layer? - Are test layers separate from live layers?
- Are resources acquired via
acquireRelease/scoped? - Does concurrent work have an explicit
concurrencycap? - After a
race, is loser cleanup verified? - Is external data validated by Effect Schema at one boundary?
- Are streams kept lazy until
Stream.run? - Do Promise/callback bridges use
tryPromise/async? - Is your adoption plan incremental rather than big-bang?
Ten anti-patterns
- Collapsing domain failures into a single
Error. - Catching defects in
try/catchand hiding real bugs. - No DI — importing clients at the module top, blocking tests.
- Opening files/connections without
acquireRelease. - Omitting concurrency caps in
Effect.all, drowning downstream. - Assuming
racelosers go uncleaned — they don't, but verify your release logic anyway. - Forcing streams to be eager.
- Migrating a large codebase in a single PR.
- Library authors forcing Effect on consumers.
- 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
- Effect — Official site
- Effect Documentation
- Effect GitHub — Effect-TS/effect
- Effect Schema — Documentation
- Effect Stream — Documentation
- Effect — Why Effect
- fp-ts — Original library
- fp-ts GitHub — gcanti/fp-ts
- Effect 3.0 announcement
- Giulio Canti joins Effect — announcement
- ZIO — Scala effect system
- neverthrow — Result for TypeScript
- ts-belt — Functional helpers
- zod — schema validation
- TC39 Explicit Resource Management proposal
- Effect Days conference
- Bun runtime
- Vercel engineering blog
- Michael Arnaldi — Effect talks
- ReScript Result and effect community
- Haskell IO monad
- Rust ? operator and Result
- F# Computation Expressions
- Effect Schema vs zod — community discussion
- Structured concurrency — Wikipedia
현재 단락 (1/364)
Look at the code below. It is the shape most TypeScript apps still ship.