Idiomatic Effect in TypeScript

Effect code becomes hard to read when every function is wrapped in a generator before anyone has decided what is actually effectful. The syntax is visible, but the design is still hidden: which work can fail, which work needs a service, and which work is just a calculation?
I find it easier to start with one small feature and keep asking that question. Here the feature is placing an order. The request arrives as unknown data, the price can be calculated locally, payment crosses a network boundary, and the completed order is saved somewhere. Those four steps are enough to show where Effect helps and where ordinary TypeScript is the better tool.
These examples target
effect@4.0.0-beta.105. Effect 4 is still a beta, so its API can move between releases. The examples follow the v4 Effect source and migration notes. Pin the package before copying the snippets into an application. The examples are cumulative; imports are shown where a new module needs them. Thedeclareclient blocks are illustrative boundary adapters, not runnable SDK clients.
To reproduce the examples in a scratch TypeScript project, install the pinned package first:
pnpm add effect@4.0.0-beta.105The snippets assume strict TypeScript and a runtime with Promise and
AbortController support, such as current Node.js or a modern browser.
Begin at the boundary
An HTTP body, queue message, or browser payload is unknown. A domain function
should not have to remember that fact every time it reads a field. Decode the
input once, then pass a value with a useful type into the rest of the program.
import { Schema } from 'effect'
const OrderDraftSchema = Schema.Struct({
orderId: Schema.String,
lines: Schema.Array(
Schema.Struct({
sku: Schema.String,
unitPriceCents: Schema.Number,
quantity: Schema.Number,
}),
),
})
type OrderDraft = typeof OrderDraftSchema.Type
const decodeOrderDraft = Schema.decodeUnknownEffect(OrderDraftSchema)Schema.decodeUnknownEffect turns a failed decode into a typed Effect failure.
That makes the edge responsible for malformed input. It also means the pricing
code below can talk about an OrderDraft instead of carrying unknown through
every function. The schema checks shape; business rules such as “an order must
contain a line” can stay in the domain function where they are easy to name and
test.
Keep deterministic decisions ordinary
Pricing does not need a clock, a network connection, a fiber, or a Layer. It is a
deterministic decision over data, so I would write it as a normal function. A
Result is useful here because both outcomes remain values and there is no
reason to make the caller execute an Effect just to discover the answer.
import { Result } from 'effect'
type PricedOrder = OrderDraft & {
readonly totalCents: number
}
type PricingError =
| {
readonly _tag: 'EmptyOrder'
readonly orderId: string
}
| {
readonly _tag: 'InvalidLine'
readonly sku: string
readonly quantity: number
readonly unitPriceCents: number
}
const priceOrder = (
draft: OrderDraft,
): Result.Result<PricedOrder, PricingError> => {
if (draft.lines.length === 0) {
return Result.fail({
_tag: 'EmptyOrder',
orderId: draft.orderId,
})
}
const invalidLine = draft.lines.find(
(line) =>
!Number.isSafeInteger(line.quantity) ||
line.quantity <= 0 ||
!Number.isSafeInteger(line.unitPriceCents) ||
line.unitPriceCents < 0,
)
if (invalidLine !== undefined) {
return Result.fail({
_tag: 'InvalidLine',
sku: invalidLine.sku,
quantity: invalidLine.quantity,
unitPriceCents: invalidLine.unitPriceCents,
})
}
const totalCents = draft.lines.reduce(
(total, line) => total + line.unitPriceCents * line.quantity,
0,
)
return Result.succeed({ ...draft, totalCents })
}There is no hidden work in this function. Calling priceOrder calculates the
answer immediately, and the result is straightforward to test with table-driven
cases. When this decision becomes part of an Effect workflow, Effect.fromResult
can lift the value without changing the pricing function.
That boundary is worth preserving. Wrapping every pure transformation in Effect adds a requirement to understand without adding any useful runtime behavior.
Give external work a small capability
Payment is different. A payment provider can decline a charge, disappear for a
while, or reject with an SDK-specific value. The rest of the application should
not have to know the provider's response classes or carry unknown failures.
In Effect 4, Context.Service creates a typed service key. The service key is
what a workflow requests; it is not the implementation itself. yield* reads the
implementation from the current context and records the requirement in R.
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',
{
orderId: Schema.String,
cause: Schema.Defect(),
},
) {}
type ChargeRequest = {
readonly orderId: string
readonly amountCents: number
readonly idempotencyKey: string
}
type PaymentReceipt = {
readonly paymentId: string
}
class PaymentGateway extends Context.Service<
PaymentGateway,
{
readonly charge: (
request: ChargeRequest,
) => Effect.Effect<PaymentReceipt, PaymentDeclined | PaymentUnavailable>
}
>()('shop/PaymentGateway') {}PaymentDeclined and PaymentUnavailable are different pieces of information.
A decline is a business response that can be shown to a customer. An unavailable
provider is an operational failure that may be retried or placed on a recovery
path. Schema.TaggedError gives each case a stable _tag and keeps its fields
available to code that handles the failure. The cause is useful for internal
diagnostics; an HTTP response should still choose deliberately which fields are
safe to expose.
Translate the Promise once
The adapter is the one place that knows how the SDK behaves. Effect.tryPromise
accepts an AbortSignal, so forwarding that signal lets interruption reach the
real request. Its catch function turns an arbitrary rejection into the one
failure the service contract promises.
import { Effect, Layer } from 'effect'
type PaymentResponse =
| {
readonly status: 'accepted'
readonly paymentId: string
}
| {
readonly status: 'declined'
readonly reason: string
}
declare const paymentsClient: {
readonly charge: (
request: ChargeRequest,
signal: AbortSignal,
) => Promise<PaymentResponse>
}
const PaymentGatewayLayer = Layer.succeed(
PaymentGateway,
PaymentGateway.of({
charge: Effect.fn('PaymentGateway.charge')(function* (
request: ChargeRequest,
) {
const result = yield* Effect.tryPromise({
try: (signal) => paymentsClient.charge(request, signal),
catch: (cause) =>
new PaymentUnavailable({
orderId: request.orderId,
cause,
}),
})
if (result.status === 'declined') {
return yield* new PaymentDeclined({
orderId: request.orderId,
reason: result.reason,
})
}
return { paymentId: result.paymentId }
}),
}),
)The service contract now hides the SDK details without hiding the decision
points. A caller can handle PaymentDeclined and PaymentUnavailable without
having to guess whether a rejected Promise means “card declined,” “provider
timed out,” or “the SDK changed its error shape.”
The idempotencyKey in ChargeRequest is intentional. A timeout after a
provider accepted a charge is ambiguous. Retrying that request is safe only if
the provider treats the key as an idempotency key, or if the adapter has another
way to prove that the charge has not already happened. Effect can express the
retry policy; it cannot make a payment operation idempotent for you.
Keep persistence separate
Saving an order is another capability with a different owner and a different failure policy. Keeping it separate makes the workflow's dependency list useful: adding a mailer later should not turn the payment service into an application-wide container.
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') {}The interface says nothing about a database driver, connection pool, or SQL statement. Those details belong in the Layer that implements it. If constructing the repository acquires a pool, use a scoped resource in that Layer so the runtime owns its cleanup rather than leaving a connection lifetime to chance.
Here is the same Promise-to-Effect translation for a storage client:
declare const orderStoreClient: {
readonly save: (order: CompletedOrder, signal: AbortSignal) => Promise<void>
}
const OrderRepositoryLayer = Layer.succeed(
OrderRepository,
OrderRepository.of({
save: Effect.fn('OrderRepository.save')((order: CompletedOrder) =>
Effect.tryPromise({
try: (signal) => orderStoreClient.save(order, signal),
catch: (cause) =>
new OrderStoreUnavailable({
orderId: order.orderId,
cause,
}),
}),
),
}),
)There is no reason for OrderRepository to know how payment works, and no
reason for the workflow to catch an SDK exception. Each adapter translates its
own boundary and exports a small vocabulary to the rest of the program.
Let the workflow read like the feature
Now the business sequence is short enough to read without mentally expanding a
dependency object. Effect.fn gives the operation a useful name for tracing;
the generator keeps the order of the steps 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,
idempotencyKey: `order:${pricedOrder.orderId}`,
})
.pipe(
Effect.retry({
// Two retries after the first attempt: at most three attempts total.
times: 2,
while: (error) => error._tag === 'PaymentUnavailable',
}),
)
const completedOrder = { ...pricedOrder, ...payment }
yield* orderRepository.save(completedOrder)
return completedOrder
})The type of placeOrder is inferred from those lines. Its success value is a
CompletedOrder; its requirements include PaymentGateway and
OrderRepository; its expected failures include the pricing, payment, and
storage cases. There is no second list of failures to keep in sync with the
implementation.
The retry sits directly on the payment operation because that is where its
meaning is understood. It retries only PaymentUnavailable. It does not retry a
decline, invalid input, or a failed save. A retry around the entire workflow
would make a persistence failure capable of charging the customer again, which is
exactly the kind of policy that should be visible in a code review.
There is another boundary worth calling out: charging and saving are not one atomic transaction just because they appear in one generator. If payment is accepted and storage fails, the provider may have a charge that the application has not recorded. The right response depends on the system: an idempotent provider, a durable outbox, a reconciliation job, or a different ordering of operations may be required. Effect keeps that failure explicit; it does not invent a distributed transaction.
Build the runtime at the edge
Layers describe how services are constructed. A ManagedRuntime builds the
layer lazily, caches its context for later runs, and owns resources acquired by
that layer until it is disposed. That makes it a good bridge between an Effect
program and a framework handler or queue consumer.
import { Effect, Layer, ManagedRuntime } from 'effect'
const AppLayer = Layer.mergeAll(PaymentGatewayLayer, OrderRepositoryLayer)
const runtime = ManagedRuntime.make(AppLayer)
export const handleOrder = (body: unknown) =>
runtime.runPromise(decodeOrderDraft(body).pipe(Effect.flatMap(placeOrder)))runtime.runPromise is the interop boundary: the host gets a Promise, while the
workflow keeps its typed failures and service requirements until this point. A
handler can catch those failures and map them to HTTP responses. When the host
needs to inspect success and failure as data instead, runtime.runPromiseExit
returns an Exit rather than rejecting.
Create the runtime once for the process or application lifetime, not once per
request. Dispose it during host shutdown so a database pool or other scoped
resource is closed. Calling Effect.runPromise inside PaymentGateway or
OrderRepository would erase the exact boundary that lets the caller choose
retry, timeout, logging, and error-mapping policies.
Test behavior by replacing capabilities
The workflow does not need a global mock. A test supplies another implementation of the same service keys. This test makes payment decline immediately and keeps the repository harmless because the workflow should stop before saving.
const PaymentDeclinedLayer = Layer.succeed(
PaymentGateway,
PaymentGateway.of({
charge: ({ orderId }) =>
Effect.fail(
new PaymentDeclined({
orderId,
reason: 'insufficient funds',
}),
),
}),
)
const OrderRepositoryTestLayer = Layer.succeed(
OrderRepository,
OrderRepository.of({
save: () => Effect.succeed(undefined),
}),
)
const TestLayer = Layer.mergeAll(PaymentDeclinedLayer, OrderRepositoryTestLayer)
const failure = await Effect.runPromise(
placeOrder({
orderId: 'order-123',
lines: [
{
sku: 'keyboard',
unitPriceCents: 12_900,
quantity: 1,
},
],
}).pipe(Effect.provide(TestLayer), Effect.flip),
)
failure._tag // 'PaymentDeclined'The expected result is a resolved PaymentDeclined value: failure._tag is
'PaymentDeclined' and its orderId is 'order-123'. Effect.flip makes the
typed failure available as the resolved value for this test; no external
payment request runs here.
The same pattern can test a transient payment failure followed by success, storage failure, cancellation, or a malformed request. Each test chooses which capability it is exercising and leaves the rest of the workflow unchanged.
A practical boundary map
In this example, the shape of the program is easy to explain to another person:
the handler decodes unknown, pricing returns a pure Result, adapters turn
Promises into typed capabilities, the workflow composes those capabilities, and
the runtime is built where the host can own it. If a new requirement is added,
the design question is concrete: is it another pure rule, another service, or a
policy around an existing operation?
That question is more useful than trying to maximize the amount of Effect code. It leaves the code with ordinary TypeScript where ordinary TypeScript is enough, and makes the genuinely asynchronous, fallible, resourceful parts visible in the types and in the runtime boundary.