Effect-TS: Typed Functional Error Handling in TypeScript

Photo by Kurt:S via Wikimedia Commons (CC BY 2.0)
Effect is a functional effect system for TypeScript, published as the effect package. It lets you describe a computation as an immutable value whose type carries three things: what it returns on success, how it can fail, and what dependencies it needs. Side effects only run when you execute the description at the edge of your program.
Failures are ordinary values that live in the error channel of the Effect type, not exceptions that jump up the stack. You model expected failures as tagged error classes with Data.TaggedError, then handle them with catchTag or catchAll. The compiler tracks the remaining error union and forces you to handle every case, so no failure is silently swallowed.
The Effect type is written Effect of Success, Error, Requirements. Success is the value produced when it runs, Error is the union of expected failures, and Requirements is the set of services the effect needs from its context. When you combine effects, the error and requirement unions merge automatically.
You declare a service as a Tag using Context.Tag, and any effect that uses it accumulates that Tag in its requirements channel. You supply an implementation with a Layer via Effect.provide. An unmet dependency is a compile-time type error rather than a runtime crash, and swapping a live layer for a test layer is a one-line change with no mocking framework.
No. Effect has a real learning curve and adds overhead that a small script or thin CRUD endpoint does not need. It pays off in long-lived backends with genuine complexity in failure modes, concurrency, or dependency wiring. Adopt it incrementally at the boundary of one module before rewriting anything.

Photo by Kurt:S via Wikimedia Commons (CC BY 2.0)
Key Takeaway
Effect (the effect-ts library) encodes success, failure, and dependencies directly in one type, Effect of Success, Error, Requirements. Errors become values the compiler tracks instead of hidden exceptions, async work composes without try/catch pyramids, and dependency injection is type-checked at the call site. It targets TypeScript 5.4 or newer with strict mode on.
TypeScript's type system is excellent right up until something fails. A function typed to return a User can still throw, reject, or return null at runtime, and the signature says nothing about it. You find out in production. On a service I run, I spent an afternoon chasing an unhandled promise rejection that three layers of async code had quietly swallowed, and the stack trace pointed at a line that was merely the messenger. That class of bug is exactly what Effect is built to make impossible to ignore.
Effect is a functional effect system for TypeScript. Instead of running side effects immediately, you build an immutable description of a workflow, then execute it at the edge of your program. The payload is the type itself: it carries not just what a computation returns, but every way it can fail and everything it needs to run. This article walks through the Effect type, typed errors, taming async, and dependency injection with Context and Layer.
Every Effect value has three type parameters, in this order: the success type, the error type, and the requirements type. A Promise only tells you the success type; rejection is typed as any. Effect makes all three explicit, so the signature becomes a contract the compiler enforces. When you combine two effects, their error unions and requirement unions merge automatically, so nothing silently disappears.
// Effect<Success, Error, Requirements>
import { Effect } from "effect"
// Succeeds with a number, cannot fail, needs nothing.
const answer: Effect.Effect<number> = Effect.succeed(42)
// Always fails with a string in the error channel.
const boom: Effect.Effect<never, string> = Effect.fail("nope")
// Effects are lazy descriptions. Nothing runs until you execute:
Effect.runSync(answer) // 42 (synchronous)
Effect.runPromise(answer) // Promise<42> (for async effects)Read a signature left to right as a sentence: Effect of User, UserNotFound or DbError, Database means this returns a User, may fail with UserNotFound or DbError, and needs a Database in context. If a new failure appears anywhere downstream, the error channel grows and any un-handled case surfaces as a type error at compile time.
The core shift is that failures are ordinary values living in the error channel, not control-flow that jumps up the stack. You model each expected failure as a tagged error class using Data.TaggedError, which stamps a string discriminant field onto the class. That discriminant is what lets the compiler tell your failure cases apart and force you to handle each one.
import { Effect, Data } from "effect"
class UserNotFound extends Data.TaggedError("UserNotFound")<{
readonly id: string
}> {}
class DbError extends Data.TaggedError("DbError")<{
readonly cause: unknown
}> {}
declare const findRow: (id: string) => Effect.Effect<Row, DbError>
const getUser = (id: string) =>
findRow(id).pipe(
Effect.flatMap((row) =>
row === null
? Effect.fail(new UserNotFound({ id }))
: Effect.succeed(row)
)
)
// inferred: Effect<Row, DbError | UserNotFound, never>To recover, catchTag handles one failure by its discriminant and removes it from the error channel; catchAll handles everything at once. After you handle a tag, the compiler narrows the remaining error union, so you can see at a glance which failures are still outstanding. This is the part that pays off on real code: the type shrinks as you handle cases, and it will not let you forget one.
const safe = getUser("u_123").pipe(
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ id: e.id, name: "guest" })
),
Effect.catchTag("DbError", (e) => {
console.error(e.cause)
return Effect.succeed({ id: "anon", name: "guest" })
})
)
// inferred error channel is now: never — every case handledWrapping a Promise is where most people first feel the difference. Effect.tryPromise runs a promise-returning function and lets you map any rejection into a typed error of your choosing, so the any-typed rejection of raw Promises becomes a named value in the error channel. From there, timeouts, retries with backoff, and concurrency limits are combinators you attach to the description rather than bespoke plumbing you hand-write for each call.
import { Effect, Schedule, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{
readonly status: number
}> {}
const fetchUser = (id: string) =>
Effect.tryPromise({
try: (signal) => fetch(`/api/users/${id}`, { signal }),
catch: () => new FetchError({ status: 0 })
}).pipe(
Effect.filterOrFail(
(res) => res.ok,
(res) => new FetchError({ status: res.status })
),
Effect.flatMap((res) => Effect.promise(() => res.json())),
Effect.timeout("5 seconds"),
Effect.retry(Schedule.exponential("200 millis").pipe(
Schedule.compose(Schedule.recurs(3))
))
)Effect.tryPromise passes an AbortSignal into your try function. If you wrap fetch, thread that signal through, or a timeout or interruption will cancel the effect while the underlying request keeps running. Forgetting the signal turns Effect's cancellation into a lie and leaks connections under load.
The third type parameter, requirements, is where dependency injection lives. You declare a service as a Tag, a unique identifier the compiler tracks. Any effect that uses the service accumulates that Tag in its requirements channel, and it simply will not run until you provide an implementation. There is no runtime container, no decorator magic, and no missing-provider crash at startup: an unmet dependency is a type error.
import { Effect, Context, Layer } from "effect"
// 1. Declare the service interface as a Tag.
class Database extends Context.Tag("Database")<
Database,
{ readonly query: (sql: string) => Effect.Effect<Row[]> }
>() {}
// 2. Use it — Database shows up in the requirements channel.
const listUsers = Effect.gen(function* () {
const db = yield* Database
return yield* db.query("SELECT * FROM users")
})
// inferred: Effect<Row[], never, Database>
// 3. Provide an implementation as a Layer.
const DatabaseLive = Layer.succeed(Database, {
query: (sql) => Effect.succeed([/* ...rows */])
})
// 4. Wire it up at the edge — requirements channel becomes never.
Effect.runPromise(listUsers.pipe(Effect.provide(DatabaseLive)))Layers are recipes that build services, possibly from other services, and can acquire and release resources safely. Swapping DatabaseLive for a DatabaseTest layer in a test is a one-line change with no mocking framework, because the dependency was always an explicit input rather than a hidden import. For anyone coming from NestJS, this is the same idea as providers and modules, but the wiring is verified by the type-checker instead of resolved at runtime.
Effect is not free. The learning curve is real, error messages can get long, and a small script does not need any of this. Be honest about the trade-off before you adopt it across a team.
| Concern | Plain TypeScript | Effect |
|---|---|---|
| Error visibility | Hidden; throws are untyped | Typed in the error channel |
| Async cancellation | Manual AbortController wiring | Built-in interruption |
| Dependency injection | Runtime container or imports | Type-checked via requirements |
| Retry and timeout | Hand-rolled per call | Composable Schedule combinators |
| Learning curve | Low | Steep up front |
My rule of thumb: reach for Effect when a codebase has genuine complexity in failure modes, concurrency, or dependency wiring, especially a long-lived backend where correctness under partial failure matters more than lines of code. For a one-off CLI or a thin CRUD endpoint, plain async plus a Result type is plenty. Adopt it incrementally, at the boundary of one module first, and let the typed error channel earn its keep before you rewrite everything.