Idiomatic Effect in TypeScript

Idiomatic Effect in TypeScript cover image

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:

  • A is the value the program can produce.
  • E is an expected failure the caller may need to handle.
  • R is 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 | InvalidQuantity

The 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.tryPromise scattered through domain functions,
  • Effect.runPromise below the application boundary,
  • one service containing every dependency in the application,
  • catch turning 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 Scope owning 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.