Idiomatic Effect in TypeScript

Effect gets hard to read when it is treated as a more complicated spelling of
Promise. The code fills up with Effect.gen, yield*, and Layers, but the
architecture stays exactly as implicit as it was before.
Idiomatic Effect starts somewhere else: the type should tell the truth about the program. A workflow says what it can produce, which expected failures it can return, and which capabilities it needs. Pure decisions stay pure. External uncertainty is translated at the edge. The runtime appears once, at the application boundary.
These examples target
effect@4.0.0-beta.105. Effect 4 is still in beta and differs from the stable Effect 3 API. Pin the version and check the Effect 4 source and migration notes before copying the examples into a long-lived project.
The type is the first design review
The useful way to read Effect.Effect<A, E, R> is:
Ais the value the program can produce.Eis an expected failure the caller may need to handle.Ris a capability the program needs before it can run.
For an order workflow, that might become CompletedOrder on success,
PaymentDeclined or OrderStoreUnavailable on failure, and a requirement for
PaymentGateway and OrderRepository.
That is already an architectural review. If a function claims it cannot fail
but calls a remote payment provider, the type is hiding something. If every
function requires a giant AppService, the boundaries are too broad. If a pure
calculation requires a runtime, Effect is being used where plain TypeScript is
clearer.
Keep decisions pure
Pricing an order does not need a network, a clock, cancellation, or dependency
injection. It should be an ordinary function with an ordinary data result.
OrderDraft is trusted domain data in this example; decode unknown JSON with
Schema at the HTTP boundary before it reaches this function.
import { Result, Schema } from 'effect'
type OrderLine = {
readonly sku: string
readonly unitPriceCents: number
readonly quantity: number
}
type OrderDraft = {
readonly orderId: string
readonly lines: ReadonlyArray<OrderLine>
}
type PricedOrder = OrderDraft & {
readonly totalCents: number
}
class EmptyOrder extends Schema.TaggedError<EmptyOrder>()('EmptyOrder', {
orderId: Schema.String,
}) {}
class InvalidQuantity extends Schema.TaggedError<InvalidQuantity>()(
'InvalidQuantity',
{
sku: Schema.String,
quantity: Schema.Number,
},
) {}
type PricingError = EmptyOrder | InvalidQuantityThe error classes are data. They name cases the caller understands; they are
not log messages wearing an Error object.
function priceOrder(
draft: OrderDraft,
): Result.Result<PricedOrder, PricingError> {
if (draft.lines.length === 0) {
return Result.fail(new EmptyOrder({ orderId: draft.orderId }))
}
const invalidLine = draft.lines.find(
(line) => !Number.isInteger(line.quantity) || line.quantity <= 0,
)
if (invalidLine) {
return Result.fail(
new InvalidQuantity({
sku: invalidLine.sku,
quantity: invalidLine.quantity,
}),
)
}
const totalCents = draft.lines.reduce(
(total, line) => total + line.unitPriceCents * line.quantity,
0,
)
return Result.succeed({ ...draft, totalCents })
}This function evaluates now. It has no hidden work and needs no test Layer. The
Effect workflow can lift its result later with Effect.fromResult.
Translate uncertainty at the edge
A payment SDK usually gives you a Promise and rejects with unknown. Do not
let that uncertainty leak through the rest of the application. Give the domain
a small capability with failures that mean something.
import { Context, Effect, Schema } from 'effect'
class PaymentDeclined extends Schema.TaggedError<PaymentDeclined>()(
'PaymentDeclined',
{
orderId: Schema.String,
reason: Schema.String,
},
) {}
class PaymentUnavailable extends Schema.TaggedError<PaymentUnavailable>()(
'PaymentUnavailable',
{ cause: Schema.Defect() },
) {}
type ChargeRequest = {
readonly orderId: string
readonly amountCents: number
}
type PaymentReceipt = { readonly paymentId: string }
class PaymentGateway extends Context.Service<
PaymentGateway,
{
readonly charge: (
request: ChargeRequest,
) => Effect.Effect<PaymentReceipt, PaymentDeclined | PaymentUnavailable>
}
>()('shop/PaymentGateway') {}The interface does not expose an SDK client, HTTP response, or unknown. It
describes one business capability. The live adapter owns the messy translation.
import { Effect, Layer } from 'effect'
type PaymentResult =
| { readonly status: 'accepted'; readonly paymentId: string }
| { readonly status: 'declined'; readonly reason: string }
declare const paymentsClient: {
readonly charge: (
request: ChargeRequest,
signal: AbortSignal,
) => Promise<PaymentResult>
}
const PaymentGatewayLive = Layer.succeed(
PaymentGateway,
PaymentGateway.of({
charge: Effect.fn('PaymentGateway.charge')(function* (request) {
const result = yield* Effect.tryPromise({
try: (signal) => paymentsClient.charge(request, signal),
catch: (cause) => new PaymentUnavailable({ cause }),
})
if (result.status === 'declined') {
return yield* new PaymentDeclined({
orderId: request.orderId,
reason: result.reason,
})
}
return { paymentId: result.paymentId }
}),
}),
)Effect.tryPromise belongs here because this is where Promise code enters the
Effect system. Passing its AbortSignal to the client also lets interruption
reach the actual request instead of merely abandoning its result.
A decline and an unavailable provider remain different. One is a business answer. The other may be worth retrying.
Services are capabilities, not containers
Persistence gets another narrow interface. It should not be folded into the payment service or hidden in a closure captured by the workflow.
class OrderStoreUnavailable extends Schema.TaggedError<OrderStoreUnavailable>()(
'OrderStoreUnavailable',
{
orderId: Schema.String,
cause: Schema.Defect(),
},
) {}
type CompletedOrder = PricedOrder & PaymentReceipt
class OrderRepository extends Context.Service<
OrderRepository,
{
readonly save: (
order: CompletedOrder,
) => Effect.Effect<void, OrderStoreUnavailable>
}
>()('shop/OrderRepository') {}This is enough interface. Configuration, connection pools, telemetry exporters,
and vendor clients belong in the Layer that implements it. If an implementation
acquires a resource, that Layer can use Effect.acquireRelease and Scope to
own its lifetime. The business workflow does not need to know how cleanup works.
Orchestrate one readable workflow
Effect.fn gives an effectful function a useful name for traces and stack
diagnostics. Effect.gen-style code keeps the business sequence visible.
import { Effect } from 'effect'
const placeOrder = Effect.fn('placeOrder')(function* (draft: OrderDraft) {
const paymentGateway = yield* PaymentGateway
const orderRepository = yield* OrderRepository
const pricedOrder = yield* Effect.fromResult(priceOrder(draft))
const payment = yield* paymentGateway
.charge({
orderId: pricedOrder.orderId,
amountCents: pricedOrder.totalCents,
})
.pipe(
Effect.retry({
times: 2,
while: (error) => error._tag === 'PaymentUnavailable',
}),
)
const completedOrder = { ...pricedOrder, ...payment }
yield* orderRepository.save(completedOrder)
return completedOrder
})Read it from top to bottom: obtain capabilities, make the pure decision, charge, save, return. There is no manually written union of every possible error. Effect infers it from the operations that can fail.
The retry policy sits beside the decision to charge because this is where its
meaning is known. It retries PaymentUnavailable at most twice. It never
retries PaymentDeclined, invalid input, or a persistence failure. A blanket
retry on the entire workflow could charge the customer again after the payment
succeeded and saving failed.
Assemble once and run at the edge
Layers describe how capabilities are built. They should be composed at an application boundary, not reconstructed inside every request.
import { Layer, ManagedRuntime } from 'effect'
import { OrderRepositoryLive } from './order-repository-live'
const AppLive = Layer.mergeAll(PaymentGatewayLive, OrderRepositoryLive)
const runtime = ManagedRuntime.make(AppLive)
export const runOrder = (draft: OrderDraft) =>
runtime.runPromiseExit(placeOrder(draft))ManagedRuntime is the bridge for a framework handler, queue consumer, or
legacy callback. Build it once, reuse it, and dispose it with the host
application. For a standalone process, use the platform's runMain at the real
entry point.
Calling Effect.runPromise inside a service is the opposite direction. It
erases the service's requirements and typed failures, prevents its caller from
composing policies, and often leaves resource lifetime unclear.
Tests replace capabilities
The workflow does not need a mocking framework. A test supplies a different Layer with the same capability contract.
const PaymentGatewayDeclined = Layer.succeed(
PaymentGateway,
PaymentGateway.of({
charge: ({ orderId }) =>
Effect.fail(
new PaymentDeclined({
orderId,
reason: 'insufficient funds',
}),
),
}),
)
const OrderRepositoryTest = Layer.succeed(
OrderRepository,
OrderRepository.of({
save: () => Effect.succeed(undefined),
}),
)
const TestApp = Layer.mergeAll(PaymentGatewayDeclined, OrderRepositoryTest)The behavior assertion can stay small:
const error = await Effect.runPromise(
placeOrder({
orderId: 'order-123',
lines: [{ sku: 'keyboard', unitPriceCents: 12_900, quantity: 1 }],
}).pipe(Effect.provide(TestApp), Effect.flip),
)
expect(error._tag).toBe('PaymentDeclined')The same boundary supports tests for transient retries, persistence failures, and interruption. The test describes behavior; it does not patch globals or reach into implementation details.
A short non-idiomatic checklist
I would stop and reshape the code when I see:
Effect.tryPromisescattered through domain functions,Effect.runPromisebelow the application boundary,- one service containing every dependency in the application,
catchturning all failures into the same generic error,- retries applied to an entire non-idempotent workflow,
- Layers assembled per request,
- pure transformations wrapped in Effect for consistency,
- resources opened without
Scopeowning their cleanup.
The goal is not to maximize the amount of Effect code. The goal is to make effects visible where the program actually interacts with uncertainty.
Keep decisions pure. Give external systems small typed interfaces. Translate unknown failures once. Compose policies without hiding them. Build the runtime at the edge.
That is the point where Effect stops looking like unusual syntax and starts acting like architecture.