{"schemaVersion":1,"id":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling","title":"Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript","description":"An examination of Effect-TS: algebraic effects for TypeScript, structured concurrency, typed errors, Fiber-based scheduling, and why replacing try-catch chaos with a real effect system makes code dramatically more reliable.","language":"en","datePublished":"2025-11-27","dateModified":null,"contentFormat":"text/markdown","content":"TypeScript\n\n# Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript\n\n2025-11-279 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/effect-ts-algebraic-effects-error-handling>)\n\n## What Effect-TS Actually Is\n\n[Effect](<https://effect.website/>) is a TypeScript library that gives you a single type, ` Effect<Success, Error, Requirements> `, that represents a computation that:\n\n- Produces a value of type ` Success `\n- Can fail with an error of type ` Error `\n- Requires services of type ` Requirements ` to run\n\nThe 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.\n\nThe following is the same order processing rewritten with Effect:\n\n```typescript\nimport { Effect, pipe } from \"effect\"\n\nclass OrderNotFound extends Data.TaggedError(\"OrderNotFound\")<{\n orderId: string\n}> {}\n\nclass PaymentFailed extends Data.TaggedError(\"PaymentFailed\")<{\n orderId: string\n status: string\n}> {}\n\nclass EmailFailed extends Data.TaggedError(\"EmailFailed\")<{\n email: string\n cause: unknown\n}> {}\n\nconst processOrder = (orderId: string) =>\n pipe(\n // Step 1: Find the order\n db.orders.findById(orderId),\n Effect.flatMap((order) =>\n order === null\n ? Effect.fail(new OrderNotFound({ orderId }))\n : Effect.succeed(order)\n ),\n\n // Step 2: Charge payment\n Effect.flatMap((order) =>\n pipe(\n stripe.charges.create({\n amount: order.total,\n currency: \"usd\",\n source: order.paymentMethodId,\n }),\n Effect.flatMap((payment) =>\n payment.status !== \"succeeded\"\n ? Effect.fail(new PaymentFailed({ orderId, status: payment.status }))\n : Effect.succeed({ order, payment })\n )\n )\n ),\n\n // Step 3: Update order status\n Effect.flatMap(({ order, payment }) =>\n pipe(\n db.orders.update(orderId, { status: \"paid\", paymentId: payment.id }),\n Effect.map(() => ({ order, payment }))\n )\n ),\n\n // Step 4: Send receipt (non-critical, we catch and log)\n Effect.tap(({ order }) =>\n pipe(\n emailService.sendReceipt(order.customerEmail, order),\n Effect.catchAll((cause) =>\n Effect.logWarning(\"Receipt email failed\", { cause, orderId })\n )\n )\n ),\n\n Effect.map(({ order }) => order)\n )\n```\n\nThe return type of ` processOrder ` is:\n\n```typescript\nEffect<Order, OrderNotFound | PaymentFailed, Database | Stripe | EmailService>\n```\n\nEvery 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.\n\n## Typed Errors: Practical Benefits\n\nHere is what typed errors actually provide in practice. The following is an API endpoint that calls ` processOrder `:\n\n```typescript\nconst handleOrderEndpoint = (req: Request) =>\n pipe(\n processOrder(req.params.orderId),\n\n // Handle specific errors with specific HTTP responses\n Effect.catchTags({\n OrderNotFound: (e) =>\n Effect.succeed(new Response(JSON.stringify({ error: \"Order not found\" }), { status: 404 })),\n PaymentFailed: (e) =>\n Effect.succeed(new Response(JSON.stringify({\n error: \"Payment failed\",\n status: e.status,\n retryable: e.status === \"pending\"\n }), { status: 402 })),\n }),\n\n // Success case\n Effect.map((order) =>\n new Response(JSON.stringify(order), { status: 200 })\n )\n )\n```\n\n` 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.\n\n## Structured Concurrency: No More Dangling Promises\n\nThis 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.\n\nEffect's structured concurrency guarantees: when a parent scope exits, all child computations are interrupted.\n\n```typescript\nconst processOrderBatch = (orderIds: string[]) =>\n Effect.forEach(orderIds, (id) => processOrder(id), {\n concurrency: 10, // Max 10 concurrent\n batching: true, // Enable request batching\n discard: false, // Keep results\n })\n```\n\nIf any order in the batch fails, all in-flight orders are interrupted. No leaked connections, no half-processed payments. The runtime handles cleanup.\n\nFor more complex scenarios, Fibers give you fine-grained control:\n\n```typescript\nconst monitorAndProcess = (orderId: string) =>\n Effect.gen(function* () {\n // Fork a background fiber for monitoring\n const monitorFiber = yield* Effect.forkScoped(\n pipe(\n Effect.repeat(\n metrics.recordProcessingLatency(orderId),\n Schedule.spaced(\"1 second\")\n )\n )\n )\n\n // Process the order\n const result = yield* processOrder(orderId)\n\n // The monitoring fiber is automatically interrupted when the scope closes\n return result\n })\n```\n\n` 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.\n\n## The Service Pattern: Dependency Injection Done Right\n\nEffect's requirements system (` R ` in ` Effect<A, E, R> `) is essentially compile-time dependency injection:\n\n```typescript\n// Define the service interface\nclass Database extends Context.Tag(\"Database\")<\n Database,\n {\n readonly findOrder: (id: string) => Effect.Effect<Order | null, DatabaseError>\n readonly updateOrder: (id: string, data: Partial<Order>) => Effect.Effect<void, DatabaseError>\n }\n>() {}\n\n// Use it in your business logic\nconst getOrder = (id: string) =>\n pipe(\n Database,\n Effect.flatMap((db) => db.findOrder(id)),\n Effect.flatMap((order) =>\n order === null\n ? Effect.fail(new OrderNotFound({ orderId: id }))\n : Effect.succeed(order)\n )\n )\n// Type: Effect<Order, OrderNotFound | DatabaseError, Database>\n```\n\nThe ` Database ` requirement flows through the type system. You can't run this effect without providing a ` Database ` implementation. And providing it is clean:\n\n```typescript\n// Production implementation\nconst DatabaseLive = Layer.succeed(Database, {\n findOrder: (id) =>\n Effect.tryPromise({\n try: () => prisma.order.findUnique({ where: { id } }),\n catch: (e) => new DatabaseError({ cause: e }),\n }),\n updateOrder: (id, data) =>\n Effect.tryPromise({\n try: () => prisma.order.update({ where: { id }, data }),\n catch: (e) => new DatabaseError({ cause: e }),\n }),\n})\n\n// Test implementation\nconst DatabaseTest = Layer.succeed(Database, {\n findOrder: (id) => Effect.succeed(mockOrders.get(id) ?? null),\n updateOrder: (id, data) => Effect.succeed(void 0),\n})\n\n// Run with production deps\nEffect.runPromise(\n pipe(getOrder(\"123\"), Effect.provide(DatabaseLive))\n)\n\n// Run with test deps, same code, different layer\nEffect.runPromise(\n pipe(getOrder(\"123\"), Effect.provide(DatabaseTest))\n)\n```\n\nNo reflection. No decorators. No runtime magic. The types guarantee you've provided everything, and swapping implementations is a single line change.\n\n## Fiber-Based Scheduling: Why It Matters\n\nEffect uses a fiber-based runtime, not OS threads or raw Promise chains. This matters for three reasons:\n\n**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.\n\n**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.\n\n**Resource safety**: Fibers have ` Scope `, a mechanism that ensures resources are released even if the fiber is interrupted:\n\n```typescript\nconst withDatabaseConnection = Effect.acquireRelease(\n pool.getConnection(), // Acquire\n (conn) => conn.release() // Release (runs even on interruption)\n)\n\nconst query = Effect.scoped(\n pipe(\n withDatabaseConnection,\n Effect.flatMap((conn) => conn.query(\"SELECT * FROM orders\")),\n )\n)\n// Connection is ALWAYS released, regardless of success, failure, or interruption\n```\n\n## The Generator Syntax: Making It Readable\n\nA common reaction: \"those pipe chains are ugly.\" Fair. Effect has a generator-based syntax that reads like async/await:\n\n```typescript\nconst processOrder = (orderId: string) =>\n Effect.gen(function* () {\n const order = yield* db.findOrder(orderId)\n if (!order) return yield* Effect.fail(new OrderNotFound({ orderId }))\n\n const payment = yield* stripe.charge({\n amount: order.total,\n source: order.paymentMethodId,\n })\n\n if (payment.status !== \"succeeded\") {\n return yield* Effect.fail(\n new PaymentFailed({ orderId, status: payment.status })\n )\n }\n\n yield* db.updateOrder(orderId, {\n status: \"paid\",\n paymentId: payment.id,\n })\n\n // Email is non-critical\n yield* pipe(\n email.sendReceipt(order.customerEmail, order),\n Effect.catchAll((e) =>\n Effect.logWarning(\"Receipt failed\", { orderId })\n )\n )\n\n return order\n })\n```\n\nSame typed errors, same composability, same structured concurrency, but it reads almost like the original try-catch version. Except now the types tell you everything.\n\n## Real Performance: Effect's Overhead\n\nAn honest look at the cost. Benchmarking Effect against raw async/await for a realistic API handler (JSON parse → DB query → transform → JSON serialize):\n\n| Approach | Throughput (req/s) | p99 Latency | Memory per request |\n| --- | --- | --- | --- |\n| Raw async/await | 34,200 | 2.8ms | 12KB |\n| Effect | 31,400 | 3.2ms | 18KB |\n| Effect + full tracing | 28,100 | 3.9ms | 24KB |\n\nThat'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.\n\n## When NOT to Use Effect\n\nFor balance, here are the cases where Effect is not the right fit:\n\n**Small scripts or CLIs**: The learning curve and boilerplate aren't worth it for a 200-line script. Use try-catch. It's fine.\n\n**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.\n\n**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.\n\n**Truly performance-critical paths**: That 8% overhead is usually invisible, but if you're processing millions of events per second, it matters.\n\n## Migration Strategy: The Boundary Pattern\n\nA full rewrite is not necessary. Here is how to adopt Effect incrementally:\n\n1. **Wrap at the boundary**: Convert your existing functions to return Effects using ` Effect.tryPromise `\n2. **Add error types gradually**: Start with ` unknown ` errors, then refine to specific types as you understand the failure modes\n3. **Use ` Effect.runPromise ` at the edge**: Your Express/Fastify handler calls ` Effect.runPromise `, everything inside is Effect, everything outside is normal TypeScript\n4. **Introduce services later**: The DI system is powerful but not required on day one\n\n```typescript\n// Step 1: Wrap existing code\nconst legacyProcessOrder = (id: string) =>\n Effect.tryPromise({\n try: () => existingProcessOrder(id),\n catch: (e) => new LegacyError({ cause: e }),\n })\n\n// Step 2: Gradually refine\nconst processOrder = (id: string) =>\n Effect.gen(function* () {\n const order = yield* findOrder(id) // Now returns typed errors\n const payment = yield* chargePayment(order) // Typed errors\n yield* legacyUpdateOrder(order, payment) // Still wrapped legacy\n return order\n })\n```\n\n## Final Assessment\n\nEffect-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.\n\nThe 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.\n\nThe confidence that typed effects provide speaks for itself.\n\n## Frequently asked questions\n\n### Is Effect-TS production-ready?\n\nYes. 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.\n\n### How does Effect-TS compare to fp-ts?\n\nEffect-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.\n\n### Does Effect-TS add significant bundle size?\n\nThe 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **Testing trick:** Use ` Layer.succeed ` to create test implementations of your services. Effect's DI makes mocking trivial without any test framework magic.\n\n- effect-ts\n- typescript\n- algebraic-effects\n- error-handling\n- structured-concurrency\n- functional-programming\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#title"},{"level":2,"text":"What Effect-TS Actually Is","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#what-effect-ts-actually-is"},{"level":2,"text":"Typed Errors: Practical Benefits","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#typed-errors-practical-benefits"},{"level":2,"text":"Structured Concurrency: No More Dangling Promises","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#structured-concurrency-no-more-dangling-promises"},{"level":2,"text":"The Service Pattern: Dependency Injection Done Right","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#the-service-pattern-dependency-injection-done-right"},{"level":2,"text":"Fiber-Based Scheduling: Why It Matters","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#fiber-based-scheduling-why-it-matters"},{"level":2,"text":"The Generator Syntax: Making It Readable","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#the-generator-syntax-making-it-readable"},{"level":2,"text":"Real Performance: Effect's Overhead","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#real-performance-effects-overhead"},{"level":2,"text":"When NOT to Use Effect","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#when-not-to-use-effect"},{"level":2,"text":"Migration Strategy: The Boundary Pattern","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#migration-strategy-the-boundary-pattern"},{"level":2,"text":"Final Assessment","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#final-assessment"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#questions"},{"level":3,"text":"Is Effect-TS production-ready?","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#question-1"},{"level":3,"text":"How does Effect-TS compare to fp-ts?","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#question-2"},{"level":3,"text":"Does Effect-TS add significant bundle size?","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#article-tips"}],"sections":[{"heading":"Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript","level":1,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling","content":"TypeScript"},{"heading":"Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript","level":1,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#title","content":"2025-11-279 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/effect-ts-algebraic-effects-error-handling>)"},{"heading":"What Effect-TS Actually Is","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#what-effect-ts-actually-is","content":"[Effect](<https://effect.website/>) is a TypeScript library that gives you a single type, ` Effect<Success, Error, Requirements> `, that represents a computation that:\n\n- Produces a value of type ` Success `\n- Can fail with an error of type ` Error `\n- Requires services of type ` Requirements ` to run\n\nThe 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.\n\nThe following is the same order processing rewritten with Effect:\n\n```typescript\nimport { Effect, pipe } from \"effect\"\n\nclass OrderNotFound extends Data.TaggedError(\"OrderNotFound\")<{\n orderId: string\n}> {}\n\nclass PaymentFailed extends Data.TaggedError(\"PaymentFailed\")<{\n orderId: string\n status: string\n}> {}\n\nclass EmailFailed extends Data.TaggedError(\"EmailFailed\")<{\n email: string\n cause: unknown\n}> {}\n\nconst processOrder = (orderId: string) =>\n pipe(\n // Step 1: Find the order\n db.orders.findById(orderId),\n Effect.flatMap((order) =>\n order === null\n ? Effect.fail(new OrderNotFound({ orderId }))\n : Effect.succeed(order)\n ),\n\n // Step 2: Charge payment\n Effect.flatMap((order) =>\n pipe(\n stripe.charges.create({\n amount: order.total,\n currency: \"usd\",\n source: order.paymentMethodId,\n }),\n Effect.flatMap((payment) =>\n payment.status !== \"succeeded\"\n ? Effect.fail(new PaymentFailed({ orderId, status: payment.status }))\n : Effect.succeed({ order, payment })\n )\n )\n ),\n\n // Step 3: Update order status\n Effect.flatMap(({ order, payment }) =>\n pipe(\n db.orders.update(orderId, { status: \"paid\", paymentId: payment.id }),\n Effect.map(() => ({ order, payment }))\n )\n ),\n\n // Step 4: Send receipt (non-critical, we catch and log)\n Effect.tap(({ order }) =>\n pipe(\n emailService.sendReceipt(order.customerEmail, order),\n Effect.catchAll((cause) =>\n Effect.logWarning(\"Receipt email failed\", { cause, orderId })\n )\n )\n ),\n\n Effect.map(({ order }) => order)\n )\n```\n\nThe return type of ` processOrder ` is:\n\n```typescript\nEffect<Order, OrderNotFound | PaymentFailed, Database | Stripe | EmailService>\n```\n\nEvery 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."},{"heading":"Typed Errors: Practical Benefits","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#typed-errors-practical-benefits","content":"Here is what typed errors actually provide in practice. The following is an API endpoint that calls ` processOrder `:\n\n```typescript\nconst handleOrderEndpoint = (req: Request) =>\n pipe(\n processOrder(req.params.orderId),\n\n // Handle specific errors with specific HTTP responses\n Effect.catchTags({\n OrderNotFound: (e) =>\n Effect.succeed(new Response(JSON.stringify({ error: \"Order not found\" }), { status: 404 })),\n PaymentFailed: (e) =>\n Effect.succeed(new Response(JSON.stringify({\n error: \"Payment failed\",\n status: e.status,\n retryable: e.status === \"pending\"\n }), { status: 402 })),\n }),\n\n // Success case\n Effect.map((order) =>\n new Response(JSON.stringify(order), { status: 200 })\n )\n )\n```\n\n` 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."},{"heading":"Structured Concurrency: No More Dangling Promises","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#structured-concurrency-no-more-dangling-promises","content":"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.\n\nEffect's structured concurrency guarantees: when a parent scope exits, all child computations are interrupted.\n\n```typescript\nconst processOrderBatch = (orderIds: string[]) =>\n Effect.forEach(orderIds, (id) => processOrder(id), {\n concurrency: 10, // Max 10 concurrent\n batching: true, // Enable request batching\n discard: false, // Keep results\n })\n```\n\nIf any order in the batch fails, all in-flight orders are interrupted. No leaked connections, no half-processed payments. The runtime handles cleanup.\n\nFor more complex scenarios, Fibers give you fine-grained control:\n\n```typescript\nconst monitorAndProcess = (orderId: string) =>\n Effect.gen(function* () {\n // Fork a background fiber for monitoring\n const monitorFiber = yield* Effect.forkScoped(\n pipe(\n Effect.repeat(\n metrics.recordProcessingLatency(orderId),\n Schedule.spaced(\"1 second\")\n )\n )\n )\n\n // Process the order\n const result = yield* processOrder(orderId)\n\n // The monitoring fiber is automatically interrupted when the scope closes\n return result\n })\n```\n\n` 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."},{"heading":"The Service Pattern: Dependency Injection Done Right","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#the-service-pattern-dependency-injection-done-right","content":"Effect's requirements system (` R ` in ` Effect<A, E, R> `) is essentially compile-time dependency injection:\n\n```typescript\n// Define the service interface\nclass Database extends Context.Tag(\"Database\")<\n Database,\n {\n readonly findOrder: (id: string) => Effect.Effect<Order | null, DatabaseError>\n readonly updateOrder: (id: string, data: Partial<Order>) => Effect.Effect<void, DatabaseError>\n }\n>() {}\n\n// Use it in your business logic\nconst getOrder = (id: string) =>\n pipe(\n Database,\n Effect.flatMap((db) => db.findOrder(id)),\n Effect.flatMap((order) =>\n order === null\n ? Effect.fail(new OrderNotFound({ orderId: id }))\n : Effect.succeed(order)\n )\n )\n// Type: Effect<Order, OrderNotFound | DatabaseError, Database>\n```\n\nThe ` Database ` requirement flows through the type system. You can't run this effect without providing a ` Database ` implementation. And providing it is clean:\n\n```typescript\n// Production implementation\nconst DatabaseLive = Layer.succeed(Database, {\n findOrder: (id) =>\n Effect.tryPromise({\n try: () => prisma.order.findUnique({ where: { id } }),\n catch: (e) => new DatabaseError({ cause: e }),\n }),\n updateOrder: (id, data) =>\n Effect.tryPromise({\n try: () => prisma.order.update({ where: { id }, data }),\n catch: (e) => new DatabaseError({ cause: e }),\n }),\n})\n\n// Test implementation\nconst DatabaseTest = Layer.succeed(Database, {\n findOrder: (id) => Effect.succeed(mockOrders.get(id) ?? null),\n updateOrder: (id, data) => Effect.succeed(void 0),\n})\n\n// Run with production deps\nEffect.runPromise(\n pipe(getOrder(\"123\"), Effect.provide(DatabaseLive))\n)\n\n// Run with test deps, same code, different layer\nEffect.runPromise(\n pipe(getOrder(\"123\"), Effect.provide(DatabaseTest))\n)\n```\n\nNo reflection. No decorators. No runtime magic. The types guarantee you've provided everything, and swapping implementations is a single line change."},{"heading":"Fiber-Based Scheduling: Why It Matters","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#fiber-based-scheduling-why-it-matters","content":"Effect uses a fiber-based runtime, not OS threads or raw Promise chains. This matters for three reasons:\n\n**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.\n\n**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.\n\n**Resource safety**: Fibers have ` Scope `, a mechanism that ensures resources are released even if the fiber is interrupted:\n\n```typescript\nconst withDatabaseConnection = Effect.acquireRelease(\n pool.getConnection(), // Acquire\n (conn) => conn.release() // Release (runs even on interruption)\n)\n\nconst query = Effect.scoped(\n pipe(\n withDatabaseConnection,\n Effect.flatMap((conn) => conn.query(\"SELECT * FROM orders\")),\n )\n)\n// Connection is ALWAYS released, regardless of success, failure, or interruption\n```"},{"heading":"The Generator Syntax: Making It Readable","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#the-generator-syntax-making-it-readable","content":"A common reaction: \"those pipe chains are ugly.\" Fair. Effect has a generator-based syntax that reads like async/await:\n\n```typescript\nconst processOrder = (orderId: string) =>\n Effect.gen(function* () {\n const order = yield* db.findOrder(orderId)\n if (!order) return yield* Effect.fail(new OrderNotFound({ orderId }))\n\n const payment = yield* stripe.charge({\n amount: order.total,\n source: order.paymentMethodId,\n })\n\n if (payment.status !== \"succeeded\") {\n return yield* Effect.fail(\n new PaymentFailed({ orderId, status: payment.status })\n )\n }\n\n yield* db.updateOrder(orderId, {\n status: \"paid\",\n paymentId: payment.id,\n })\n\n // Email is non-critical\n yield* pipe(\n email.sendReceipt(order.customerEmail, order),\n Effect.catchAll((e) =>\n Effect.logWarning(\"Receipt failed\", { orderId })\n )\n )\n\n return order\n })\n```\n\nSame typed errors, same composability, same structured concurrency, but it reads almost like the original try-catch version. Except now the types tell you everything."},{"heading":"Real Performance: Effect's Overhead","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#real-performance-effects-overhead","content":"An honest look at the cost. Benchmarking Effect against raw async/await for a realistic API handler (JSON parse → DB query → transform → JSON serialize):\n\n| Approach | Throughput (req/s) | p99 Latency | Memory per request |\n| --- | --- | --- | --- |\n| Raw async/await | 34,200 | 2.8ms | 12KB |\n| Effect | 31,400 | 3.2ms | 18KB |\n| Effect + full tracing | 28,100 | 3.9ms | 24KB |\n\nThat'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."},{"heading":"When NOT to Use Effect","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#when-not-to-use-effect","content":"For balance, here are the cases where Effect is not the right fit:\n\n**Small scripts or CLIs**: The learning curve and boilerplate aren't worth it for a 200-line script. Use try-catch. It's fine.\n\n**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.\n\n**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.\n\n**Truly performance-critical paths**: That 8% overhead is usually invisible, but if you're processing millions of events per second, it matters."},{"heading":"Migration Strategy: The Boundary Pattern","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#migration-strategy-the-boundary-pattern","content":"A full rewrite is not necessary. Here is how to adopt Effect incrementally:\n\n1. **Wrap at the boundary**: Convert your existing functions to return Effects using ` Effect.tryPromise `\n2. **Add error types gradually**: Start with ` unknown ` errors, then refine to specific types as you understand the failure modes\n3. **Use ` Effect.runPromise ` at the edge**: Your Express/Fastify handler calls ` Effect.runPromise `, everything inside is Effect, everything outside is normal TypeScript\n4. **Introduce services later**: The DI system is powerful but not required on day one\n\n```typescript\n// Step 1: Wrap existing code\nconst legacyProcessOrder = (id: string) =>\n Effect.tryPromise({\n try: () => existingProcessOrder(id),\n catch: (e) => new LegacyError({ cause: e }),\n })\n\n// Step 2: Gradually refine\nconst processOrder = (id: string) =>\n Effect.gen(function* () {\n const order = yield* findOrder(id) // Now returns typed errors\n const payment = yield* chargePayment(order) // Typed errors\n yield* legacyUpdateOrder(order, payment) // Still wrapped legacy\n return order\n })\n```"},{"heading":"Final Assessment","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#final-assessment","content":"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.\n\nThe 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.\n\nThe confidence that typed effects provide speaks for itself."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#questions","content":""},{"heading":"Is Effect-TS production-ready?","level":3,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#question-1","content":"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."},{"heading":"How does Effect-TS compare to fp-ts?","level":3,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#question-2","content":"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."},{"heading":"Does Effect-TS add significant bundle size?","level":3,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **Testing trick:** Use ` Layer.succeed ` to create test implementations of your services. Effect's DI makes mocking trivial without any test framework magic.\n\n- effect-ts\n- typescript\n- algebraic-effects\n- error-handling\n- structured-concurrency\n- functional-programming\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/effect-ts-algebraic-effects-error-handling"},{"text":"Effect","url":"https://effect.website/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling"},"headline":"Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript","description":"An examination of Effect-TS: algebraic effects for TypeScript, structured concurrency, typed errors, Fiber-based scheduling, and why replacing try-catch chaos with a real effect system makes code dramatically more reliable.","datePublished":"2025-11-27","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"TypeScript","keywords":"effect-ts, typescript, algebraic-effects, error-handling, structured-concurrency, functional-programming","wordCount":1570,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/effect-ts-algebraic-effects-error-handling"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Effect-TS: Algebraic Effects and Typed Error Handling in TypeScript","item":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is Effect-TS production-ready?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How does Effect-TS compare to fp-ts?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Does Effect-TS add significant bundle size?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"bce9c4d9a4c91e2f037a1d2d29f4e83ef83288a3aa3b9e0c7473d7958eca681c","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":68063,"contentBytes":14051}}
