Skip to content

Guidelines

Most Effect code follows a handful of conventions that keep it readable, traceable, and testable. This page walks through the ones that matter most day to day: how to write generators, when to wrap them in Effect.fn, why async/await and try/catch have no place inside an effect, why behavior belongs in services rather than module-level globals, and why you read the time from Clock instead of Date.

import { Clock, Context, Effect, Layer, Schema } from "effect"
// Errors are schema-tagged classes. `Schema.Defect` captures an unknown cause.
class ChargeError extends Schema.TaggedErrorClass<ChargeError>()("ChargeError", {
cause: Schema.Defect
}) {}
// Behavior lives in a service, not in free functions reaching for globals.
class Payments extends Context.Service<Payments, {
readonly charge: (cents: number) => Effect.Effect<string, ChargeError>
}>()("app/Payments") {
static readonly layer = Layer.effect(
Payments,
Effect.gen(function*() {
// A function that returns an Effect is written with Effect.fn, not a
// plain arrow that returns Effect.gen. The name feeds stack traces and
// opens a tracing span automatically.
const charge = Effect.fn("Payments.charge")(function*(cents: number) {
// Read the time from Clock — never `Date.now()` — so tests can control it.
const now = yield* Clock.currentTimeMillis
yield* Effect.log(`Charging ${cents} cents at ${now}`)
// `return yield*` makes the failure terminal and keeps the types honest.
if (cents <= 0) {
return yield* new ChargeError({ cause: "amount must be positive" })
}
return `txn_${now}`
})
return Payments.of({ charge })
})
)
}

The rest of this page explains each of these choices.

Effect.gen lets you write effectful code in a familiar, imperative style. Each yield* runs an effect and unwraps its success value, much like await unwraps a promise — but with full error and requirement tracking in the type.

import { Effect } from "effect"
const program = Effect.gen(function*() {
yield* Effect.log("Starting...")
const a = yield* Effect.succeed(1)
const b = yield* Effect.succeed(2)
return a + b
})

Use Effect.gen for inline composition — one-off pipelines, the body of a layer, or steps that won’t be reused on their own.

Effect.fn for functions that return an effect

Section titled “Effect.fn for functions that return an effect”

When you write a named function whose job is to produce an effect, wrap the generator with Effect.fn(name) instead of returning a bare Effect.gen.

import { Effect } from "effect"
// The name improves stack traces and attaches a span (via Effect.withSpan).
const greet = Effect.fn("greet")(function*(name: string) {
yield* Effect.log(`Hello, ${name}`)
return name.length
})

You add extra behavior by passing further arguments to Effect.fn — do not use .pipe on the result:

import { Effect } from "effect"
const greet = Effect.fn("greet")(
function*(name: string) {
return name.length
},
// Each additional argument is applied like a pipe step.
Effect.annotateLogs({ method: "greet" })
)

When you write inline Effect.gen (not wrapped in a function), add spans and adjustments with .pipe as usual:

import { Effect } from "effect"
Effect.gen(function*() {
yield* Effect.log("working")
}).pipe(Effect.withSpan("work"))

No async/await or try/catch inside effects

Section titled “No async/await or try/catch inside effects”

Effect models concurrency and failure in the type system. Mixing in JavaScript’s own mechanisms breaks that model.

try/catch does not work inside Effect.gen: a failed effect short-circuits the generator rather than throwing a catchable exception, so the catch block never runs. Use Effect’s error handling instead.

import { Effect } from "effect"
declare const risky: Effect.Effect<number, Error>
// ❌ The catch never fires — Effect failures aren't thrown exceptions.
// Effect.gen(function*() {
// try {
// return yield* risky
// } catch (e) {
// return 0
// }
// })
// ✅ Inspect the outcome as a value with Effect.result...
const handled = Effect.gen(function*() {
const result = yield* Effect.result(risky)
return result._tag === "Success" ? result.success : 0
})
// ✅ ...or recover with a combinator. `Effect.catch` is the catch-all.
const recovered = risky.pipe(Effect.catch(() => Effect.succeed(0)))

Likewise, never reach for async/await. To lift a promise into Effect, use Effect.promise (for promises that cannot reject meaningfully) or Effect.tryPromise (to capture a rejection as a typed error):

import { Effect } from "effect"
declare function loadUser(id: string): Promise<{ name: string }>
const user = Effect.tryPromise({
try: () => loadUser("u_1"),
catch: (cause) => new Error(`failed to load user: ${cause}`)
})

Anything your code depends on — a database handle, an HTTP client, configuration, even the current time — should be reached through a service rather than a global singleton imported at the top of a module. Globals are invisible to the type system, impossible to swap in tests, and tie initialization order to import order.

import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, {
readonly apiUrl: string
}>()("app/Config") {
static readonly layer = Layer.succeed(Config, Config.of({
apiUrl: "https://api.example.com"
}))
}
// The dependency shows up in the Effect's requirements (`R`) channel, so the
// compiler forces you to provide it — and lets a test provide a fake.
const fetchUrl = Effect.fn("fetchUrl")(function*(path: string) {
const config = yield* Config
return `${config.apiUrl}${path}`
})

For configuration values, prefer Effect’s Configuration layer; for singletons with a sensible default that callers may override, use Context.Reference. See Services & Layers for the complete pattern.

Reading the wall clock directly (Date.now(), new Date()) makes code non-deterministic and untestable: every run produces different timestamps and you cannot fast-forward time. Effect exposes the clock as a service so tests can substitute a TestClock.

import { Clock, Effect } from "effect"
const stampedEvent = Effect.fn("stampedEvent")(function*(message: string) {
// Pulls from the ambient Clock service, controllable in tests.
const millis = yield* Clock.currentTimeMillis
return { message, at: millis }
})

In tests, TestClock lets you advance time explicitly so time-dependent logic — timeouts, retries, schedules — runs instantly and deterministically. See Testing and Scheduling for how this plays out in practice.

Write the function explicitly rather than passing it bare to a combinator:

import { Effect } from "effect"
declare const parse: (s: string) => number
declare const effect: Effect.Effect<string>
// ✅ Explicit — robust to overloads and clearer in stack traces
effect.pipe(Effect.map((s) => parse(s)))
// ⚠️ Tacit — can erase generics on overloaded functions and worsen inference
effect.pipe(Effect.map(parse))

Tacit usage looks tidy, but when the passed function has overloads or optional parameters it can silently drop generics and produce surprising types. Spelling out the argument is a cheap habit that prevents a class of subtle bugs.