Qué Es Realmente Effect-TS

Effect 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:

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:

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:

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.

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:

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:

// 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:

// 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:

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:

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
// 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.