Idiomatic Effect in TypeScript

Idiomatic Effect in TypeScript cover image

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. The declare client 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.105

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