<!-- Canonical: https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling
Language: en
Content-SHA256: bce9c4d9a4c91e2f037a1d2d29f4e83ef83288a3aa3b9e0c7473d7958eca681c
-->

TypeScript

# Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript

2025-11-279 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/effect-ts-algebraic-effects-error-handling>)

## What Effect-TS Actually Is

[Effect](<https://effect.website/>) is a TypeScript library that gives you a single type, ` Effect<Success, Error, Requirements> `, that represents a computation that:

- Produces a value of type ` Success `
- Can fail with an error of type ` Error `
- Requires services of type ` Requirements ` to run

The key insight: errors are typed. When you call a function that returns ` Effect<Order, PaymentError | NotFoundError, Database | Stripe> `, you know exactly what can go wrong, and the compiler forces you to handle it.

The following is the same order processing rewritten with Effect:

```typescript
import { Effect, pipe } from "effect"

class OrderNotFound extends Data.TaggedError("OrderNotFound")<{
 orderId: string
}> {}

class PaymentFailed extends Data.TaggedError("PaymentFailed")<{
 orderId: string
 status: string
}> {}

class EmailFailed extends Data.TaggedError("EmailFailed")<{
 email: string
 cause: unknown
}> {}

const processOrder = (orderId: string) =>
 pipe(
 // Step 1: Find the order
 db.orders.findById(orderId),
 Effect.flatMap((order) =>
 order === null
 ? Effect.fail(new OrderNotFound({ orderId }))
 : Effect.succeed(order)
 ),

 // Step 2: Charge payment
 Effect.flatMap((order) =>
 pipe(
 stripe.charges.create({
 amount: order.total,
 currency: "usd",
 source: order.paymentMethodId,
 }),
 Effect.flatMap((payment) =>
 payment.status !== "succeeded"
 ? Effect.fail(new PaymentFailed({ orderId, status: payment.status }))
 : Effect.succeed({ order, payment })
 )
 )
 ),

 // Step 3: Update order status
 Effect.flatMap(({ order, payment }) =>
 pipe(
 db.orders.update(orderId, { status: "paid", paymentId: payment.id }),
 Effect.map(() => ({ order, payment }))
 )
 ),

 // Step 4: Send receipt (non-critical, we catch and log)
 Effect.tap(({ order }) =>
 pipe(
 emailService.sendReceipt(order.customerEmail, order),
 Effect.catchAll((cause) =>
 Effect.logWarning("Receipt email failed", { cause, orderId })
 )
 )
 ),

 Effect.map(({ order }) => order)
 )
```

The return type of ` processOrder ` is:

```typescript
Effect<Order, OrderNotFound | PaymentFailed, Database | Stripe | EmailService>
```

Every caller can see exactly what errors are possible. The email failure is handled locally, it logs a warning but doesn't propagate. The payment and not-found errors bubble up for the caller to decide.

## Typed Errors: Practical Benefits

Here is what typed errors actually provide in practice. The following is an API endpoint that calls ` processOrder `:

```typescript
const handleOrderEndpoint = (req: Request) =>
 pipe(
 processOrder(req.params.orderId),

 // Handle specific errors with specific HTTP responses
 Effect.catchTags({
 OrderNotFound: (e) =>
 Effect.succeed(new Response(JSON.stringify({ error: "Order not found" }), { status: 404 })),
 PaymentFailed: (e) =>
 Effect.succeed(new Response(JSON.stringify({
 error: "Payment failed",
 status: e.status,
 retryable: e.status === "pending"
 }), { status: 402 })),
 }),

 // Success case
 Effect.map((order) =>
 new Response(JSON.stringify(order), { status: 200 })
 )
 )
```

` Effect.catchTags ` does pattern matching on the error type. If you add a new error variant to ` processOrder `, the compiler immediately tells you about every call site that needs updating. Try getting that guarantee with try-catch.

## Structured Concurrency: No More Dangling Promises

This is where Effect gets genuinely exciting. JavaScript's ` Promise.all ` has a fundamental problem: if one promise fails, the others keep running. You can't cancel them. Resources leak. Side effects happen that shouldn't.

Effect's structured concurrency guarantees: when a parent scope exits, all child computations are interrupted.

```typescript
const processOrderBatch = (orderIds: string[]) =>
 Effect.forEach(orderIds, (id) => processOrder(id), {
 concurrency: 10, // Max 10 concurrent
 batching: true, // Enable request batching
 discard: false, // Keep results
 })
```

If any order in the batch fails, all in-flight orders are interrupted. No leaked connections, no half-processed payments. The runtime handles cleanup.

For more complex scenarios, Fibers give you fine-grained control:

```typescript
const monitorAndProcess = (orderId: string) =>
 Effect.gen(function* () {
 // Fork a background fiber for monitoring
 const monitorFiber = yield* Effect.forkScoped(
 pipe(
 Effect.repeat(
 metrics.recordProcessingLatency(orderId),
 Schedule.spaced("1 second")
 )
 )
 )

 // Process the order
 const result = yield* processOrder(orderId)

 // The monitoring fiber is automatically interrupted when the scope closes
 return result
 })
```

` Effect.forkScoped ` ensures the monitoring fiber lives exactly as long as its parent scope. When ` processOrder ` completes (or fails), the fiber is interrupted. No cleanup code. No forgotten ` clearInterval `. The structure of your code defines the lifecycle.

## The Service Pattern: Dependency Injection Done Right

Effect's requirements system (` R ` in ` Effect<A, E, R> `) is essentially compile-time dependency injection:

```typescript
// Define the service interface
class Database extends Context.Tag("Database")<
 Database,
 {
 readonly findOrder: (id: string) => Effect.Effect<Order | null, DatabaseError>
 readonly updateOrder: (id: string, data: Partial<Order>) => Effect.Effect<void, DatabaseError>
 }
>() {}

// Use it in your business logic
const getOrder = (id: string) =>
 pipe(
 Database,
 Effect.flatMap((db) => db.findOrder(id)),
 Effect.flatMap((order) =>
 order === null
 ? Effect.fail(new OrderNotFound({ orderId: id }))
 : Effect.succeed(order)
 )
 )
// Type: Effect<Order, OrderNotFound | DatabaseError, Database>
```

The ` Database ` requirement flows through the type system. You can't run this effect without providing a ` Database ` implementation. And providing it is clean:

```typescript
// Production implementation
const DatabaseLive = Layer.succeed(Database, {
 findOrder: (id) =>
 Effect.tryPromise({
 try: () => prisma.order.findUnique({ where: { id } }),
 catch: (e) => new DatabaseError({ cause: e }),
 }),
 updateOrder: (id, data) =>
 Effect.tryPromise({
 try: () => prisma.order.update({ where: { id }, data }),
 catch: (e) => new DatabaseError({ cause: e }),
 }),
})

// Test implementation
const DatabaseTest = Layer.succeed(Database, {
 findOrder: (id) => Effect.succeed(mockOrders.get(id) ?? null),
 updateOrder: (id, data) => Effect.succeed(void 0),
})

// Run with production deps
Effect.runPromise(
 pipe(getOrder("123"), Effect.provide(DatabaseLive))
)

// Run with test deps, same code, different layer
Effect.runPromise(
 pipe(getOrder("123"), Effect.provide(DatabaseTest))
)
```

No reflection. No decorators. No runtime magic. The types guarantee you've provided everything, and swapping implementations is a single line change.

## Fiber-Based Scheduling: Why It Matters

Effect uses a fiber-based runtime, not OS threads or raw Promise chains. This matters for three reasons:

**Interruption**: Any fiber can be interrupted at any yield point. This is what makes structured concurrency work, the runtime can cancel in-flight work without cooperation from the fiber.

**Fairness**: The scheduler yields between fibers, preventing any single computation from starving others. If you have 10,000 concurrent effects, they'll all make progress.

**Resource safety**: Fibers have ` Scope `, a mechanism that ensures resources are released even if the fiber is interrupted:

```typescript
const withDatabaseConnection = Effect.acquireRelease(
 pool.getConnection(), // Acquire
 (conn) => conn.release() // Release (runs even on interruption)
)

const query = Effect.scoped(
 pipe(
 withDatabaseConnection,
 Effect.flatMap((conn) => conn.query("SELECT * FROM orders")),
 )
)
// Connection is ALWAYS released, regardless of success, failure, or interruption
```

## The Generator Syntax: Making It Readable

A common reaction: "those pipe chains are ugly." Fair. Effect has a generator-based syntax that reads like async/await:

```typescript
const processOrder = (orderId: string) =>
 Effect.gen(function* () {
 const order = yield* db.findOrder(orderId)
 if (!order) return yield* Effect.fail(new OrderNotFound({ orderId }))

 const payment = yield* stripe.charge({
 amount: order.total,
 source: order.paymentMethodId,
 })

 if (payment.status !== "succeeded") {
 return yield* Effect.fail(
 new PaymentFailed({ orderId, status: payment.status })
 )
 }

 yield* db.updateOrder(orderId, {
 status: "paid",
 paymentId: payment.id,
 })

 // Email is non-critical
 yield* pipe(
 email.sendReceipt(order.customerEmail, order),
 Effect.catchAll((e) =>
 Effect.logWarning("Receipt failed", { orderId })
 )
 )

 return order
 })
```

Same typed errors, same composability, same structured concurrency, but it reads almost like the original try-catch version. Except now the types tell you everything.

## Real Performance: Effect's Overhead

An honest look at the cost. Benchmarking Effect against raw async/await for a realistic API handler (JSON parse → DB query → transform → JSON serialize):

| Approach | Throughput (req/s) | p99 Latency | Memory per request |
| --- | --- | --- | --- |
| Raw async/await | 34,200 | 2.8ms | 12KB |
| Effect | 31,400 | 3.2ms | 18KB |
| Effect + full tracing | 28,100 | 3.9ms | 24KB |

That's ~8% overhead for the base case, ~18% with tracing enabled. For most applications, this is noise, your database query takes 5-50ms anyway. But for extremely latency-sensitive hot paths (game servers, high-frequency trading), it's worth knowing.

## When NOT to Use Effect

For balance, here are the cases where Effect is not the right fit:

**Small scripts or CLIs**: The learning curve and boilerplate aren't worth it for a 200-line script. Use try-catch. It's fine.

**Frontend components**: Effect's value proposition is strongest server-side. For UI state management, the overhead (both cognitive and runtime) rarely pays off. Use React's built-in error boundaries and your preferred state library.

**Teams unfamiliar with FP concepts**: Effect has a real learning curve. If your team struggles with ` Promise.then ` chains, Effect's ` pipe ` and ` flatMap ` will be a wall. Invest in education first.

**Truly performance-critical paths**: That 8% overhead is usually invisible, but if you're processing millions of events per second, it matters.

## Migration Strategy: The Boundary Pattern

A full rewrite is not necessary. Here is how to adopt Effect incrementally:

1. **Wrap at the boundary**: Convert your existing functions to return Effects using ` Effect.tryPromise `
2. **Add error types gradually**: Start with ` unknown ` errors, then refine to specific types as you understand the failure modes
3. **Use ` Effect.runPromise ` at the edge**: Your Express/Fastify handler calls ` Effect.runPromise `, everything inside is Effect, everything outside is normal TypeScript
4. **Introduce services later**: The DI system is powerful but not required on day one

```typescript
// Step 1: Wrap existing code
const legacyProcessOrder = (id: string) =>
 Effect.tryPromise({
 try: () => existingProcessOrder(id),
 catch: (e) => new LegacyError({ cause: e }),
 })

// Step 2: Gradually refine
const processOrder = (id: string) =>
 Effect.gen(function* () {
 const order = yield* findOrder(id) // Now returns typed errors
 const payment = yield* chargePayment(order) // Typed errors
 yield* legacyUpdateOrder(order, payment) // Still wrapped legacy
 return order
 })
```

## Final Assessment

Effect-TS is the most significant library to hit the TypeScript ecosystem since Prisma. It doesn't just give you better error handling, it gives you a fundamentally different (and better) model for composing side effects, managing resources, and reasoning about failure.

The learning curve is real. Budget two weeks for a team to become comfortable. But once it clicks, once there is experience with the confidence of seeing ` Effect<Order, OrderNotFound | PaymentFailed, Database | Stripe> ` as your return type, it becomes hard to imagine writing production code without it.

The confidence that typed effects provide speaks for itself.

## Frequently asked questions

### Is Effect-TS production-ready?

Yes. Effect reached 3.x stable in late 2025 and is used in production by several companies. The API surface is large but the core is solid and well-tested.

### How does Effect-TS compare to fp-ts?

Effect-TS is the spiritual successor to fp-ts but takes a fundamentally different approach. Instead of HKTs and type-class instances, Effect uses a single Effect type with a powerful runtime. It's more practical and has better TypeScript inference.

### Does Effect-TS add significant bundle size?

The core Effect library is around 50KB gzipped with tree-shaking. It's not trivial, but for server-side applications (where Effect shines most), bundle size is rarely a concern.

## Tips

1. **Migration path:** Start by replacing a single ` try-catch ` block with ` Effect.tryPromise ` and mapping the error. You don't need to rewrite your whole app, Effect composes incrementally.
2. **Type-level errors:** Always use ` Effect<A, E, R> ` with a specific error union for ` E `, never ` unknown `. The whole point is that callers see exactly what can fail.
3. **Fiber gotcha:** ` Effect.fork ` creates a daemon fiber by default, it won't prevent the parent from exiting. Use ` Effect.forkScoped ` when you need the parent to wait.
4. **Testing trick:** Use ` Layer.succeed ` to create test implementations of your services. Effect's DI makes mocking trivial without any test framework magic.

- effect-ts
- typescript
- algebraic-effects
- error-handling
- structured-concurrency
- functional-programming

[Back to Blog](<https://lucioduran.com/blog>)
