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

TypeScript

# Effect-TS: Efectos Algebraicos y Manejo de Errores Tipado en TypeScript

2025-11-279 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/effect-ts-algebraic-effects-error-handling>)

## Qué Es Realmente Effect-TS

[Effect](<https://effect.website/>) es una librería TypeScript que te da un solo tipo, ` Effect<Success, Error, Requirements> `, que representa una computación que:

- Produce un valor de tipo ` Success `
- Puede fallar con un error de tipo ` Error `
- Requiere servicios de tipo ` Requirements ` para ejecutarse

El insight clave: los errores están tipados. Cuando llamás a una función que retorna ` Effect<Order, PaymentError | NotFoundError, Database | Stripe> `, sabés exactamente qué puede salir mal, y el compilador te fuerza a manejarlo.

Este es el puntotá el mismo procesamiento de orden reescrito con 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(
 // Paso 1: Buscar la orden
 db.orders.findById(orderId),
 Effect.flatMap((order) =>
 order === null
 ? Effect.fail(new OrderNotFound({ orderId }))
 : Effect.succeed(order)
 ),

 // Paso 2: Cobrar pago
 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 })
 )
 )
 ),

 // Paso 3: Actualizar estado de la orden
 Effect.flatMap(({ order, payment }) =>
 pipe(
 db.orders.update(orderId, { status: "paid", paymentId: payment.id }),
 Effect.map(() => ({ order, payment }))
 )
 ),

 // Paso 4: Enviar recibo (no crítico, catcheamos y logueamos)
 Effect.tap(({ order }) =>
 pipe(
 emailService.sendReceipt(order.customerEmail, order),
 Effect.catchAll((cause) =>
 Effect.logWarning("Email de recibo falló", { cause, orderId })
 )
 )
 ),

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

El tipo de retorno de ` processOrder ` es:

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

Cada caller puede ver exactamente qué errores son posibles. La falla del email se maneja localmente, loguea un warning pero no se propaga. Los errores de pago y not-found burbujean para que el caller decida.

## Errores Tipados: Beneficios Prácticos

Esto es lo que los errores tipados aportan en la práctica. Acá hay un endpoint de API que llama a ` processOrder `:

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

 // Manejar errores específicos con respuestas HTTP específicas
 Effect.catchTags({
 OrderNotFound: (e) =>
 Effect.succeed(new Response(JSON.stringify({ error: "Orden no encontrada" }), { status: 404 })),
 PaymentFailed: (e) =>
 Effect.succeed(new Response(JSON.stringify({
 error: "Pago fallido",
 status: e.status,
 retryable: e.status === "pending"
 }), { status: 402 })),
 }),

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

` Effect.catchTags ` hace pattern matching sobre el tipo de error. Si agregars un nuevo variante de error a ` processOrder `, el compilador inmediatamente te avisa sobre cada call site que necesita actualizarse. Intentá obtener esa garantía con try-catch.

## Concurrencia Estructurada: No Más Promises Colgadas

Este es el punto donde Effect se pone genuinamente emocionante. ` Promise.all ` de JavaScript tiene un problema fundamental: si una promise falla, las demás siguen corriendo. No las es posible cancelar. Los recurse es se filtran. Efectos secundarios pasan que no deberían.

La concurrencia estructurada de Effect garantiza: cuando un scope padre sale, todas las computaciones hijas son interrumpidas.

```typescript
const processOrderBatch = (orderIds: string[]) =>
 Effect.forEach(orderIds, (id) => processOrder(id), {
 concurrency: 10, // Máximo 10 concurrentes
 batching: true, // Habilitar batching de requests
 discard: false, // Mantener resultados
 })
```

Si cualquier orden en el batch falla, todas las órdenes in-flight son interrumpidas. Sin conexiones filtradas, sin pagos procesados a medias. El runtime maneja la limpieza.

Para escenarios más complejos, los Fibers te dan control fino:

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

 // Procesar la orden
 const result = yield* processOrder(orderId)

 // El fiber de monitoreo se interrumpe automáticamente cuando el scope cierra
 return result
 })
```

` Effect.forkScoped ` asegura que el fiber de monitoreo viva exactamente lo mismo que su scope padre. Cuando ` processOrder ` completa (o falla), el fiber se interrumpe. Sin código de limpieza. Sin ` clearInterval ` olvidado. La estructura de tu código define el ciclo de vida.

## El Patrón de Servicios: Dependency Injection Bien Hecho

El sistema de requirements de Effect (` R ` en ` Effect<A, E, R> `) es esencialmente inyección de dependencias en tiempo de compilación:

```typescript
// Definir la interfaz del servicio
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>
 }
>() {}

// Usarlo en tu lógica de negocio
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)
 )
 )
// Tipo: Effect<Order, OrderNotFound | DatabaseError, Database>
```

El requirement ` Database ` fluye a través del sistema de tipos. No es posible ejecutar este efecto sin proveer una implementación de ` Database `. Y proveerla es limpio:

```typescript
// Implementación de producción
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 }),
 }),
})

// Implementación de test
const DatabaseTest = Layer.succeed(Database, {
 findOrder: (id) => Effect.succeed(mockOrders.get(id) ?? null),
 updateOrder: (id, data) => Effect.succeed(void 0),
})

// Ejecutar con deps de producción
Effect.runPromise(
 pipe(getOrder("123"), Effect.provide(DatabaseLive))
)

// Ejecutar con deps de test, mismo código, diferente layer
Effect.runPromise(
 pipe(getOrder("123"), Effect.provide(DatabaseTest))
)
```

Sin reflection. Sin decorators. Sin magia de runtime. Los tipos garantizan que proveíste todo, y swapear implementaciones es un cambio de una línea.

## Scheduling Basado en Fibers: Por Qué Importa

Effect usa un runtime basado en fibers, no threads del OS ni cadenas crudas de Promise. Esto importa por tres razones:

**Interrupción**: Cualquier fiber puede ser interrumpido en cualquier punto de yield. Esto es lo que hace funcionar la concurrencia estructurada, el runtime puede cancelar trabajo in-flight sin cooperación del fiber.

**Equidad**: El scheduler hace yield entre fibers, previniendo que una sola computación mate de hambre a las demás. Si teners 10,000 efectos concurrentes, todos van a progresar.

**Seguridad de recursos**: Los fibers tienen ` Scope `, un mecanismo que asegura que los recurse es se liberen incluso si el fiber es interrumpido:

```typescript
const withDatabaseConnection = Effect.acquireRelease(
 pool.getConnection(), // Adquirir
 (conn) => conn.release() // Liberar (corre incluso en interrupción)
)

const query = Effect.scoped(
 pipe(
 withDatabaseConnection,
 Effect.flatMap((conn) => conn.query("SELECT * FROM orders")),
 )
)
// La conexión SIEMPRE se libera, sin importar éxito, falla o interrupción
```

## La Sintaxis de Generadores: Haciéndolo Legible

Una reacción común: "esas cadenas de pipe son feas." Justo. Effect tiene una sintaxis basada en generadores que se lee como 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 no es crítico
 yield* pipe(
 email.sendReceipt(order.customerEmail, order),
 Effect.catchAll((e) =>
 Effect.logWarning("Recibo falló", { orderId })
 )
 )

 return order
 })
```

Mismos errores tipados, misma composabilidad, misma concurrencia estructurada, pero se lee casi como la versión original con try-catch. Excepto que ahora los tipos te dicen todo.

## Performance Real: El Overhead de Effect

Una mirada honesta al costo. Al benchmarkear Effect contra async/await crudo para un handler de API realista (JSON parse -\> query a DB -\> transformación -\> JSON serialize):

| Enfoque | Throughput (req/s) | Latencia p99 | Memoria por request |
| --- | --- | --- | --- |
| Async/await crudo | 34,200 | 2.8ms | 12KB |
| Effect | 31,400 | 3.2ms | 18KB |
| Effect + tracing completo | 28,100 | 3.9ms | 24KB |

Eso es ~8% de overhead para el caso base, ~18% con tracing habilitado. Para la mayoría de las aplicaciones, es ruido, tu query a la base de datos toma 5-50ms de todos modos. Pero para hot paths extremadamente sensibles a latencia (servidores de juegos, trading de alta frecuencia), vale la pena saberlo.

## Cuándo NO Usar Effect

Para equilibrar, estos son los case es donde Effect no es la herramienta correcta:

**Scripts chicos o CLIs**: La curva de aprendizaje y el boilerplate no valen para un script de 200 líneas. Utilizar try-catch. Está bien.

**Componentes de frontend**: La propuesta de valor de Effect es más fuerte del lado del servidor. Para manejo de estado de UI, el overhead (tanto cognitivo como de runtime) rara vez paga. Utilizar los error boundaries de React y tu librería de estado preferida.

**Equipos sin familiaridad con conceptos FP**: Effect tiene una curva de aprendizaje real. Si tu equipo lucha con cadenas de ` Promise.then `, el ` pipe ` y ` flatMap ` de Effect van a ser un muro. Invertí en educación primero.

**Paths genuinamente críticos en performance**: Ese 8% de overhead generalmente es invisible, pero si se está procesando millones de eventos por segundo, importa.

## Estrategia de Migración: El Patrón de Frontera

No es necesario reescribir todo. Así se adopta Effect incrementalmente:

1. **Wrapeá en la frontera**: Convertí tus funciones existentes para que retornen Effects usando ` Effect.tryPromise `
2. **Agregar tipos de error gradualmente**: Empezá con errores ` unknown `, después refiná a tipos específicos mientras entendés los modos de falla
3. **Utilizar ` Effect.runPromise ` en el borde**: Tu handler de Express/Fastify llama ` Effect.runPromise `, todo adentro es Effect, todo afuera es TypeScript normal
4. **Introducí servicios después**: El sistema de DI es poderoso pero no es requerido en el día uno

```typescript
// Paso 1: Wrapear código existente
const legacyProcessOrder = (id: string) =>
 Effect.tryPromise({
 try: () => existingProcessOrder(id),
 catch: (e) => new LegacyError({ cause: e }),
 })

// Paso 2: Refinar gradualmente
const processOrder = (id: string) =>
 Effect.gen(function* () {
 const order = yield* findOrder(id) // Ahora retorna errores tipados
 const payment = yield* chargePayment(order) // Errores tipados
 yield* legacyUpdateOrder(order, payment) // Todavía legacy wrapeado
 return order
 })
```

## Evaluación Final

Effect-TS es la librería más significativa que llegó al ecosistema TypeScript desde Prisma. No te da solo mejor manejo de errores, te da un modelo fundamentalmente diferente (y mejor) para componer efectos secundarios, manejar recurse es y razonar sobre fallas.

La curva de aprendizaje es real. Presupuestar dos semanas para que un equipo se ponga cómodo. Pero una vez que hace click, una vez que se experimenta la confianza de ver ` Effect<Order, OrderNotFound | PaymentFailed, Database | Stripe> ` como tu tipo de retorno, se vuelve difícil imaginar escribir código de producción sin esto.

La confianza que los efectos tipados proveen habla por sí sola.

## Preguntas frecuentes

### ¿Effect-TS está listo para producción?

Sí. Effect alcanzó 3.x estable a fines de 2025 y lo usan en producción varias empresas. La superficie de API es grande pero el core es sólido y bien testeado.

### ¿Cómo se compara Effect-TS con fp-ts?

Effect-TS es el sucesor espiritual de fp-ts pero toma un enfoque fundamentalmente diferente. En lugar de HKTs e instancias de type-class, Effect usa un solo tipo Effect con un runtime poderoso. Es más práctico y tiene mejor inferencia de TypeScript.

### ¿Effect-TS agrega tamaño significativo al bundle?

La librería core de Effect es alrededor de 50KB gzipped con tree-shaking. No es trivial, pero para aplicaciones del lado del servidor (donde Effect brilla más), el tamaño del bundle rara vez es una preocupación.

## Tips

1. **Camino de migración:** Comenzar reemplazando un solo bloque ` try-catch ` con ` Effect.tryPromise ` y mapeando el error. No se necesita reescribir toda tu app, Effect compone incrementalmente.
2. **Errores a nivel de tipo:** Utilizar siempre ` Effect<A, E, R> ` con una unión de errores específica para ` E `, nunca ` unknown `. El punto es que los callers vean exactamente qué puede fallar.
3. **Gotcha de Fiber:** ` Effect.fork ` crea un fiber daemon por defecto, no va a impedir que el padre termine. Utilizar ` Effect.forkScoped ` cuando se necesita que el padre espere.
4. **Truco para testing:** Utilizar ` Layer.succeed ` para crear implementaciones de test de tus servicios. La DI de Effect hace que mockear sea trivial sin ninguna magia de framework de test.

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

[Volver al Blog](<https://lucioduran.com/es/blog>)
