Effect is a TypeScript framework for building production-grade applications using a functional effect system. It gives you typed errors, structured concurrency, dependency injection, and built-in observability — all composable, all in one monorepo.
Why I starred it
Most TypeScript error handling is theater. You throw, you catch, you lose type information. You write async functions that can fail in twelve ways and the caller has no idea. Effect takes the opposite position: every failure mode is encoded in the type signature.
The type is Effect<A, E, R> — success type, error type, and required services. You can't run an effect that needs a database service without providing one. The compiler enforces it.
This isn't a new idea — Scala has ZIO, Haskell has IO — but Effect is the first time I've seen it done in TypeScript at this scale and with enough ecosystem around it to be practical. The monorepo includes @effect/sql, @effect/platform, @effect/opentelemetry, @effect/cli, @effect/ai, and more. It's not a library. It's a platform.
How it works
The core abstraction is built on a minimal instruction set. I opened packages/effect/src/internal/core.ts and found EffectPrimitive — a plain class with three instruction slots (effect_instruction_i0, effect_instruction_i1, effect_instruction_i2) and an opcode string. Every effect in the system is one of these primitives:
class EffectPrimitive {
public effect_instruction_i0 = undefined
public effect_instruction_i1 = undefined
public effect_instruction_i2 = undefined
constructor(readonly _op: Primitive["_op"]) {}
}
The opcodes live in packages/effect/src/internal/opCodes/effect.ts:
export const OP_ASYNC = "Async" as const
export const OP_SUCCESS = "Success" as const
export const OP_SYNC = "Sync" as const
export const OP_ON_FAILURE = "OnFailure" as const
export const OP_ON_SUCCESS = "OnSuccess" as const
export const OP_WHILE = "While" as const
// ...
This is an interpreter pattern. Every Effect.map, Effect.flatMap, Effect.retry — they don't execute anything. They build a tree of primitives. The actual execution happens in FiberRuntime in packages/effect/src/internal/fiberRuntime.ts, which has a tight evaluation loop with three signals: Continue, Done, and Yield.
The Yield signal is how concurrency works. The scheduler interrupts a fiber after it hits its op count budget (currentOpCount), yields to other fibers, then resumes. No preemption at arbitrary points — just cooperative yielding at known suspension points, which makes the execution model predictable and debuggable.
Error handling goes through Cause<E>. The docstring in packages/effect/src/Cause.ts says it plainly: "the error model is lossless." A Cause is a tree that can hold sequential failures, parallel failures, defects (unexpected errors), and interruptions — all at once. If two concurrent fibers both fail, you get a Parallel cause containing both. Nothing is dropped.
The Layer<ROut, E, RIn> type is how dependency injection works. A Layer is a recipe that produces services (typed as ROut), may fail with E, and requires other services RIn to build itself. Layers compose with Layer.provide and are memoized by default — if the same layer appears twice in the graph, it's allocated once. The implementation in packages/effect/src/internal/layer.ts builds a proper DAG and handles resource acquisition and release via Scope.
Using it
The generator-based API makes chaining feel natural:
import { Effect, Console } from "effect"
const program = Effect.gen(function* () {
const result = yield* Effect.tryPromise({
try: () => fetch("https://api.example.com/data"),
catch: (e) => new Error(`fetch failed: ${e}`)
})
const json = yield* Effect.tryPromise({
try: () => result.json(),
catch: (e) => new Error(`parse failed: ${e}`)
})
yield* Console.log(json)
})
Effect.runPromise(program)
Dependency injection uses Context.Tag to declare services and Layer to provide them:
import { Context, Effect, Layer } from "effect"
class Database extends Context.Tag("Database")<
Database,
{ readonly query: (sql: string) => Effect.Effect<unknown[]> }
>() {}
const DatabaseLive = Layer.succeed(Database, {
query: (sql) => Effect.succeed([]) // real impl here
})
const program = Effect.gen(function* () {
const db = yield* Database
return yield* db.query("SELECT 1")
})
Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)))
The fiber metrics are baked into the runtime. fiberRuntime.ts exports fiberStarted, fiberActive, fiberSuccesses, fiberFailures — all tracked automatically and compatible with @effect/opentelemetry.
Rough edges
The learning curve is real. You're adopting a mental model, not just an API. Concepts like Cause, Layer, Scope, FiberRef, Deferred, and Ref all have to click before the pieces fit together. The docs at effect.website are solid, but the surface area is enormous.
The type errors can be brutal. Because everything is generic over A, E, and R, a mismatch three layers deep produces error messages that take time to read. TypeScript inference helps in the happy path; the unhappy path requires knowing what you're looking at.
The monorepo has 13,800+ stars but the API has stabilized relatively recently — there were breaking changes moving from the fp-ts lineage to the current unified Effect-TS shape. If you're picking this up for a greenfield project now, the API surface is stable. If you adopted it two years ago, you've already lived through the migration.
Bundle size is not a concern if you're on Node.js. On the browser, you'll want to measure — the core package is tree-shakeable but the ecosystem packages add up.
Bottom line
Effect is the right answer if you're building a TypeScript backend that needs reliable error handling, structured concurrency, and testable dependency injection — and you want those three things to compose instead of fighting each other. If you're building a weekend script, the overhead isn't worth it.
