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
Requirementspara 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:
- Wrapeá en la frontera: Convertí tus funciones existentes para que retornen Effects usando
Effect.tryPromise - Agregar tipos de error gradualmente: Empezá con errores
unknown, después refiná a tipos específicos mientras entendés los modos de falla - Utilizar
Effect.runPromiseen el borde: Tu handler de Express/Fastify llamaEffect.runPromise, todo adentro es Effect, todo afuera es TypeScript normal - 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.