An async callback returns a Promise the Effect runtime never awaits — the work dangles or the value becomes Effect<Promise<T>>. Use Effect.tryPromise / Effect.promise for async work.
instead of Effect.map(user, async (u) => await enrich(u))
write Effect.flatMap(user, (u) => Effect.tryPromise(() => enrich(u)))
Schema classes control construction for decoding — overriding the constructor breaks decode. Use a static make/factory or field transformations instead.
instead of class User extends Schema.Class<User>("User")({ id: Schema.String }) {
constructor() { super({ id: "0" }) }
}
write class User extends Schema.Class<User>("User")({ id: Schema.String }) {
static empty = () => User.make({ id: "0" })
}
Running an Effect inside another Effect creates a detached runtime: context, interruption, and tracing are lost. Compose with `yield*` instead, and run only at the program edge.
instead of Effect.gen(function* () {
const result = Effect.runSync(computeThing())
})
write Effect.gen(function* () {
const result = yield* computeThing()
})
Stream.runCollect buffers the whole stream — on an infinite source it runs until the process dies. Add Stream.take/takeUntil/timeout, or use Stream.runDrain.
instead of Stream.forever(poll).pipe(Stream.runCollect)
write Stream.forever(poll).pipe(Stream.take(100), Stream.runCollect)
// or Stream.runDrain when results are not needed
Effect.runSync throws AsyncFiberException when the effect suspends (promise, sleep, async). Use Effect.runPromise for anything that can be asynchronous.
instead of Effect.runSync(Effect.promise(() => fetch(url)))
write await Effect.runPromise(Effect.promise(() => fetch(url)))
A throw inside Effect code becomes an untyped defect, invisible to the error channel. Fail with a tagged error instead: `yield* Effect.fail(new MyError({...}))`.
instead of Effect.gen(function* () {
if (!valid) throw new Error("invalid input")
})
write Effect.gen(function* () {
if (!valid) return yield* new InvalidInput({ input })
})
Effect failures do not throw — they flow through the typed error channel, so the catch block is dead code for them. Handle failures with Effect.catchTag / Effect.catch.
instead of Effect.gen(function* () {
try {
return yield* fetchUser(id)
} catch (e) {
return null
}
})
write fetchUser(id).pipe(
Effect.catchTag("UserNotFound", () => Effect.succeed(null))
)
Inside Effect.gen, effects must be yielded with `yield*`. A plain `yield` hands the raw Effect to the iterator protocol and the result type is wrong.
instead of const user = yield getUser(id)
write const user = yield* getUser(id)
The Self type parameter of Schema.Class/TaggedClass/TaggedError must be the declaring class itself, or every static helper is typed against the wrong class.
instead of class Wrong extends Schema.Class<Account>("Wrong")({ id: Schema.String }) {}
write class Wrong extends Schema.Class<Wrong>("Wrong")({ id: Schema.String }) {}
v4 renamed the catch family: catchAll→catch, catchAllCause→catchCause, catchAllDefect→catchDefect, catchSome→catchFilter, catchSomeCause→catchCauseFilter; catchSomeDefect was removed.
instead of effect.pipe(Effect.catchAll(handle))
write effect.pipe(Effect.catch(handle))
v4 flattens Cause to a reasons array: isFailType→isFailReason, isFailure→hasFails, isDie→hasDies, isInterrupted→hasInterrupts, failureOption→findErrorOption, dieOption→findDefect, sequential/parallel→combine, *Exception→*Error.
instead of Cause.isFailType(cause)
write Cause.isFailReason(reason) // Cause is a flat reasons array in v4
v4 consolidates service definition: Context.Tag / Context.GenericTag / Effect.Tag / Effect.Service all become `class X extends Context.Service<X, Shape>()("X") {}`.
instead of class Db extends Context.Tag("Db")<Db, Shape>() {}
write class Db extends Context.Service<Db, Shape>()("Db") {}
v4 removed FiberRef/FiberRefs/Differ — use Context.Reference (ambient config with defaults) or References.* / Effect.provideService.
instead of import { FiberRef } from "effect"
write import { Context } from "effect"
// Context.Reference for ambient config with defaults
v4 renamed forking: fork→forkChild, forkDaemon→forkDetach; forkAll and forkWithErrorHandler were removed.
instead of yield* Effect.fork(task)
write yield* Effect.forkChild(task)
v4 changed the self-binding form: Effect.gen(this, function*(){}) becomes Effect.gen({ self: this }, function*(){}).
instead of Effect.gen(this, function* () { ... })
write Effect.gen({ self: this }, function* () { ... })
v4's Layer.effect handles scoping/finalization itself — Layer.scoped is gone; use Layer.effect.
instead of Layer.scoped(Tag, acquire)
write Layer.effect(Tag, acquire) // v4 Layer.effect handles scoping
v4 renamed Option.fromNullable to Option.fromNullishOr.
instead of Option.fromNullable(value)
write Option.fromNullishOr(value)
v4 merged these packages into effect itself: @effect/platform → effect (and effect/unstable/http), @effect/rpc → effect/unstable/rpc, @effect/cluster → effect/unstable/cluster.
instead of import { HttpClient } from "@effect/platform"
write import { HttpClient } from "effect/unstable/http"
v4 removed Runtime<R> and Effect.runtime — use Effect.context<R>() plus Effect.runForkWith(services).
instead of const runtime = yield* Effect.runtime<R>()
write const services = yield* Effect.context<R>()
// run with Effect.runForkWith(services)
v4 reworked Schema's API surface — see the effect-smol MIGRATION.md Schema table for the full mapping.
instead of Schema.Union(A, B)
Schema.Literal("a", "b")
Schema.TaggedError
write Schema.Union([A, B])
Schema.Literals(["a", "b"])
Schema.TaggedErrorClass
v4 renamed Scope.extend to Scope.provide.
instead of Scope.extend(effect, scope)
write Scope.provide(effect, scope)
Sequential awaits in a loop are the slowest possible shape. Effect.forEach(items, fn, { concurrency: N }) runs them structured and concurrent — or keep it sequential but interruptible.
instead of for (const id of ids) {
await processUser(id) // strictly sequential
}
write yield* Effect.forEach(ids, (id) => processUser(id), { concurrency: 5 })
if/else (and else-if) chains hide branching. Prefer early returns, a lookup map, or Match.value(...).pipe(Match.when(...), Match.exhaustive) for declarative, exhaustive dispatch.
instead of let label
if (level === "error") {
label = "!"
} else if (level === "warn") {
label = "?"
} else {
label = "."
}
write const label = Match.value(level).pipe(
Match.when("error", () => "!"),
Match.when("warn", () => "?"),
Match.orElse(() => ".")
)
Inline `await import(...)` / `require(...)` hides a module's dependencies mid-body — agents add them to dodge a missing top-level import. Hoist to a static top-level `import` (keep dynamic import only for deliberate code-splitting).
instead of export const load = async () => {
const { parse } = await import("./parser") // inline
const fs = require("node:fs")
return parse(fs.readFileSync(path))
}
write import { parse } from "./parser"
import * as fs from "node:fs"
export const load = () => parse(fs.readFileSync(path))
`let`/`var` signal mutation. Bind with `const` and build values functionally — Array.reduce, Match for conditional values, or an Effect pipeline instead of reassigning.
instead of let softCeiling = 0
if (hasSoft) {
softCeiling = softCap + freeAllowance
}
write const softCeiling = hasSoft ? softCap + freeAllowance : 0
// better: const softCeiling = Option.match(soft, { onNone: () => 0, onSome: (c) => c + freeAllowance })
Reassigning a binding or mutating a payload in place creates intermediate states that are hard to follow. Derive the final value in one expression (const + Match/reduce/pipe) instead of building it up by mutation.
instead of let softCeiling = 0
if (hasSoft) {
softCeiling = softCap + freeAllowance // reassigned
}
payload.total = softCeiling // mutated in place
write const softCeiling = hasSoft ? softCap + freeAllowance : 0
const payload = { ...base, total: softCeiling } // derived once, no mutation
Raw for/while loops mutate and obscure intent. Use Array combinators (map/filter/reduce) for pure data, or Effect.forEach / Effect.reduce for effectful iteration (concurrency + interruption included).
instead of const rowsByScope = new Map()
for (const row of rows) {
const list = rowsByScope.get(row.scopeId) ?? []
list.push(row)
rowsByScope.set(row.scopeId, list)
}
write const rowsByScope = Array.groupBy(rows, (row) => row.scopeId)
// effectful: yield* Effect.forEach(rows, persistRow, { concurrency: 5 })
Comparing against a string literal (`x === "user"`) is a stringly-typed guard. Use a type guard / predicate (isUser(x)) or Match.when on a tagged value for exhaustiveness.
instead of if (value.kind === "user") {
return renderUser(value)
}
write if (isUser(value)) {
return renderUser(value)
}
// or: Match.value(value).pipe(Match.tag("user", renderUser), ...)
Ternaries hide branching. Extract a named helper, or use Match.value(...).pipe(Match.when(...), Match.orElse(...)) so each branch is named and exhaustive.
instead of const tone = percent >= 100 ? "critical" : percent >= 80 ? "warning" : "neutral"
write const tone = Match.value(percent).pipe(
Match.when((p) => p >= 100, () => "critical"),
Match.when((p) => p >= 80, () => "warning"),
Match.orElse(() => "neutral")
)
catchAll(() => succeed(null)) swallows every failure — including bugs — and turns them into a nullable success. Catch the specific tag, or use Effect.option if absence is the model.
instead of getUser(id).pipe(Effect.catchAll(() => Effect.succeed(null)))
write getUser(id).pipe(
Effect.catchTag("UserNotFound", () => Effect.succeed(null))
)
Multiple Effect.provide calls in one pipe build layers independently (v3 even double-builds shared ones). Compose layers first (Layer.provide / Layer.mergeAll), then provide once.
instead of program.pipe(Effect.provide(DbLayer), Effect.provide(LogLayer))
write program.pipe(Effect.provide(Layer.mergeAll(DbLayer, LogLayer)))
Effect.fn(...)() invoked immediately builds a traced function just to call it once — use Effect.gen directly (keep the span with Effect.withSpan).
instead of yield* Effect.fn(function* () { ... })()
write yield* Effect.gen(function* () { ... })
Forking and immediately joining is just running the effect, with extra fiber overhead. Yield the effect directly; fork only when work continues in the background.
instead of const fiber = yield* Effect.fork(task)
const result = yield* Fiber.join(fiber)
write const result = yield* task
Hand-written BEGIN/COMMIT/ROLLBACK loses automatic rollback on failure and interruption. Use sql.withTransaction.
instead of yield* sql`BEGIN`
yield* insertOrder
yield* sql`COMMIT`
write yield* insertOrder.pipe(sql.withTransaction)
map wraps the callback result as a plain value, so returning an Effect produces Effect<Effect<A>> — the inner effect silently never runs. Use flatMap (or tap for side effects).
instead of Effect.map(user, (u) => Effect.log(u.name)) // log never runs
write Effect.tap(user, (u) => Effect.log(u.name))
Comparing against a fresh object/array literal uses reference equality and is always false. Use Equal.equals with Data.struct/Data.array for structural equality.
instead of selected.includes({ id: 1, name: "Paul" }) // always false
write selected.some((u) => Equal.equals(u, Data.struct({ id: 1, name: "Paul" })))
Promise.all inside an Effect wrapper bypasses concurrency limits, interruption, and structured concurrency. Use Effect.forEach(items, fn, { concurrency: N }) or Effect.all.
instead of Effect.tryPromise(() => Promise.all(ids.map(fetchUser)))
write Effect.forEach(ids, (id) => Effect.tryPromise(() => fetchUser(id)), {
concurrency: 5,
})
Failing with a string loses all type information — catchTag can't route it and call sites get `string` in the error channel. Use Data.TaggedError / Schema.TaggedErrorClass.
instead of Effect.fail("Something went wrong!")
write class QueryError extends Data.TaggedError("QueryError")<{ cause: unknown }> {}
Effect.fail(new QueryError({ cause }))
Reaching into `_tag` for built-in types bypasses the typed predicates. Use Option.isSome/isNone, Either.isLeft/isRight, Exit.isSuccess/isFailure.
instead of if (result._tag === "Left") { ... }
write if (Either.isLeft(result)) { ... }
Promise chains inside Effect.sync escape the runtime — no error channel, no interruption. Use Effect.tryPromise and compose with Effect.map/flatMap.
instead of Effect.sync(() => {
fetchData().then((d) => use(d))
})
write Effect.tryPromise(() => fetchData()).pipe(Effect.map(use))
try/finally around yields is not interruption-safe — the finalizer can be skipped if the fiber is interrupted. Use Effect.ensuring, Effect.acquireRelease, or Effect.race.
instead of Effect.gen(function* () {
const fiber = yield* Effect.fork(poller)
try {
return yield* job
} finally {
yield* Fiber.interrupt(fiber)
}
})
write job.pipe(Effect.ensuring(Fiber.interrupt(fiber)))
// or: Effect.race(job, poller) / Effect.acquireRelease
Branching on `error._tag` inside a catch-all handler re-implements what `Effect.catchTag` / `Effect.catchTags` do with full type narrowing and exhaustiveness.
instead of Effect.catchAll((e) =>
e._tag === "NotFound" ? Effect.succeed(0) : Effect.fail(e)
)
write Effect.catchTag("NotFound", () => Effect.succeed(0))
Reading the wall clock directly makes Effect code untestable. Use `Clock.currentTimeMillis` / `DateTime.now` so TestClock can control time in tests.
instead of Effect.gen(function* () {
const now = Date.now()
})
write Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis
})
Direct process.env reads are untyped and unvalidated. The Config module gives typed, validated configuration with redaction support.
instead of Effect.gen(function* () {
const port = process.env.PORT
})
write Effect.gen(function* () {
const port = yield* Config.integer("PORT")
})
Secrets loaded as plain Config.string leak into logs, errors, and serialized output. Config.redacted wraps them so they can't be printed accidentally.
instead of const apiKey = Config.string("API_KEY")
write const apiKey = Config.redacted("API_KEY")
Sync decoding throws inside Effect code, bypassing the typed error channel. Use Schema.decodeUnknownEffect so failures land in the error channel as SchemaError.
instead of Effect.gen(function* () {
const user = Schema.decodeUnknownSync(User)(input) // throws
})
write Effect.gen(function* () {
const user = yield* decodeUser(input) // Schema.decodeUnknownEffect(User)
})
A function whose body is one Effect.gen is exactly what Effect.fn is for — it adds a named span and proper stack frames for free: `Effect.fn("name")(function* (args) {...})`.
instead of const loadUser = (id: string) =>
Effect.gen(function* () {
return yield* repo.byId(id)
})
write const loadUser = Effect.fn("UserRepo.loadUser")(function* (id: string) {
return yield* repo.byId(id)
})
console.* bypasses Effect's structured, context-aware logging (levels, spans, annotations). Use Effect.log / Effect.logInfo / Effect.logError.
instead of Effect.gen(function* () {
console.log("user created", user)
})
write Effect.gen(function* () {
yield* Effect.logInfo("user created").pipe(Effect.annotateLogs({ user }))
})
Raw timers escape Effect's interruption and TestClock. Use Effect.sleep, Schedule, or Effect.repeat.
instead of Effect.sync(() => setTimeout(poll, 5000))
write poll.pipe(Effect.delay("5 seconds"), Effect.forever)
@effect/vitest's it.effect runs the test inside a managed runtime (TestClock, proper failure rendering) — no manual runPromise or async glue.
instead of it("creates a user", () => Effect.runPromise(program))
write it.effect("creates a user", () => program)
Manual JSON.stringify into a text response skips the content-type header clients rely on. HttpServerResponse.json(value) sets it correctly.
instead of HttpServerResponse.text(JSON.stringify(user))
write HttpServerResponse.json(user)
Global fetch has untyped errors and no tracing/interruption integration. Use HttpClient from effect (platform).
instead of Effect.tryPromise(() => fetch("https://api.example.com/users"))
write HttpClient.get("https://api.example.com/users")
// typed errors, tracing, interruption built in
Direct randomness makes Effect code non-deterministic in tests. Use the `Random` service (`Random.next`, `Random.nextIntBetween`, ...) so tests can seed it.
instead of Effect.sync(() => Math.random())
Plain Error subclasses have no _tag, so Effect.catchTag cannot route them and they serialize poorly. Use Data.TaggedError or Schema.TaggedErrorClass.
instead of class HttpError extends Error {}
write class HttpError extends Schema.TaggedErrorClass<HttpError>()("HttpError", {
status: Schema.Number,
}) {}
Racing against Effect.sleep hand-rolls a deadline with worse semantics. Effect.timeout / timeoutTo / timeoutFail say it directly.
instead of Effect.race(fetchData, Effect.sleep("2 seconds"))
write fetchData.pipe(Effect.timeout("2 seconds"))
The `_` adapter parameter (`Effect.gen(function*(_) { yield* _(op) })`) is deprecated and removed in v4 — yield effects directly with `yield* op`.
instead of Effect.gen(function* (_) {
const user = yield* _(getUser(id))
})
write Effect.gen(function* () {
const user = yield* getUser(id)
})
Exponential backoff without jitter synchronizes retries across clients (thundering herd). Add `Schedule.jittered`.
instead of Schedule.exponential("100 millis")
write Schedule.exponential("100 millis").pipe(Schedule.jittered)
async/await runs outside Effect: untyped errors, no interruption, no tracing. Migrate to Effect.fn (or Effect.gen) with Effect.tryPromise at the Promise boundaries.
instead of async function loadUser(id: string) {
const res = await fetch(`/users/${id}`)
return res.json()
}
write const loadUser = Effect.fn("loadUser")(function* (id: string) {
const res = yield* Effect.tryPromise({
try: () => fetch(`/users/${id}`),
catch: (cause) => new FetchError({ cause }),
})
return yield* Effect.tryPromise(() => res.json())
})
Hand-rolled Promise constructors (resolve/reject plumbing) map directly onto Effect.async — with interruption support included.
instead of new Promise((resolve, reject) => {
socket.once("data", resolve)
socket.once("error", reject)
})
write Effect.async<Buffer, SocketError>((resume) => {
socket.once("data", (d) => resume(Effect.succeed(d)))
socket.once("error", (e) => resume(Effect.fail(new SocketError({ cause: e }))))
})
Promise.all has no concurrency limit and no interruption. Effect.all / Effect.forEach with { concurrency } is the structured equivalent.
instead of await Promise.all(ids.map(fetchUser))
write yield* Effect.forEach(ids, (id) => fetchUser(id), { concurrency: 10 })
.then() chains are Effect pipelines without the safety. Wrap the source with Effect.tryPromise and compose with Effect.map / Effect.flatMap.
instead of fetchUser(id).then((user) => enrich(user)).then(save)
write Effect.tryPromise(() => fetchUser(id)).pipe(
Effect.flatMap((user) => Effect.tryPromise(() => enrich(user))),
Effect.flatMap((user) => Effect.tryPromise(() => save(user)))
)
A function with a structurally identical body exists in another file — agents re-create helpers they can't see. Import and reuse the existing one (or extract a shared module).
instead of // src/billing/format.ts
export const formatCredits = (n) => `${Math.round(n)} cr`
// src/ui/Usage.tsx — agent re-created the same helper
const formatCredits = (n) => `${Math.round(n)} cr`
write // src/billing/format.ts
export const formatCredits = (n) => `${Math.round(n)} cr`
// src/ui/Usage.tsx
import { formatCredits } from "../billing/format"
Two functions in this file share a structurally identical body — agents copy-paste instead of extracting. Hoist the shared logic into one reusable helper.
instead of const usageBarFill = (p) => p >= 100 ? red : p >= 80 ? amber : gray
const usageText = (p) => p >= 100 ? red : p >= 80 ? amber : gray // copy-paste
write const usageTone = (p) => p >= 100 ? red : p >= 80 ? amber : gray
const usageBarFill = usageTone
const usageText = usageTone
A function in another file is structurally near-identical (a lightly-edited copy). Confirm it isn't the same logic reimplemented, and consolidate to one helper.
instead of // a.ts
export const toTone = (p) => { const t = pick(p); return clamp(t) }
// b.ts — lightly-edited copy
export const barTone = (q) => { const v = pick(q); return clamp(v) }
write // tone.ts — one source of truth
export const tone = (p) => clamp(pick(p))
// a.ts / b.ts both: import { tone } from "./tone"
Another function with the same name is defined elsewhere — likely a duplicate implementation of the same concept. Reuse the existing one or rename to disambiguate.
instead of // src/a.ts
export const parseConfig = (raw) => Schema.decodeUnknownSync(Config)(raw)
// src/b.ts — same name, divergent impl
export const parseConfig = (raw) => JSON.parse(raw)
write // src/config.ts — one canonical parser
export const parseConfig = (raw) =>
Schema.decodeUnknownEffect(Config)(JSON.parse(raw))
Another function takes the same parameters and calls the same helpers — it may accomplish the same goal by a different route. Check whether one can be derived from or replaced by the other.
instead of // both take (id) and call getUser + decode + log — same job, two routes
export const loadUser = (id) => { ... }
export const fetchUser = (id) => { ... }
write // keep one; derive the other or delete it
export const loadUser = (id) => Effect.gen(function* () { ... })
export const fetchUser = loadUser
Four or more flatMap/andThen steps in one pipe read like callback nesting. Effect.gen turns the same flow into sequential statements.
instead of step1().pipe(
Effect.flatMap(step2),
Effect.flatMap(step3),
Effect.flatMap(step4),
Effect.flatMap(step5)
)
write Effect.gen(function* () {
const a = yield* step1()
const b = yield* step2(a)
const c = yield* step3(b)
return yield* step5(yield* step4(c))
})
Uncapped exponential backoff grows without bound. Union with a spaced schedule (`Schedule.either(Schedule.spaced(...))`) to cap the delay.
instead of Schedule.exponential("100 millis")
write Schedule.exponential("100 millis").pipe(
Schedule.either(Schedule.spaced("10 seconds"))
)
A catch handler that always fails again is just transforming the error — `Effect.mapError` says that directly.
instead of Effect.catchAll((e) => Effect.fail(new WrappedError({ cause: e })))
write Effect.mapError((e) => new WrappedError({ cause: e }))
Effect.all / Effect.forEach run sequentially unless you pass { concurrency } — usually a surprise for code that looks parallel. Make the choice explicit.
instead of Effect.all([fetchUser, fetchPosts]) // silently sequential
write Effect.all([fetchUser, fetchPosts], { concurrency: "unbounded" })
Schema.decode*/encode* compiles a codec — building it on every call is wasted work. Hoist `const decode = Schema.decodeUnknownEffect(MySchema)` to module scope.
instead of const parse = (u: unknown) => Schema.decodeUnknownEffect(User)(u)
write const decodeUser = Schema.decodeUnknownEffect(User)
const parse = (u: unknown) => decodeUser(u)
Span names end up in traces and dashboards — name the business operation ("UserRepo.create"), not the mechanism.
instead of Effect.fn("run")(function* () { ... })
write Effect.fn("OrderService.placeOrder")(function* () { ... })
Stream.fromChunk(Chunk.fromIterable(x)) materializes the entire iterable up front. Stream.fromIterable(x) streams it lazily.
instead of Stream.fromChunk(Chunk.fromIterable(bigIterable))
write Stream.fromIterable(bigIterable)
Do-notation pipelines (Effect.Do / bind / bindTo / let) read worse than the equivalent Effect.gen and lose stack quality.
instead of Effect.Do.pipe(
Effect.bind("user", () => getUser(id)),
Effect.bind("posts", ({ user }) => getPosts(user))
)
write Effect.gen(function* () {
const user = yield* getUser(id)
const posts = yield* getPosts(user)
return { user, posts }
})
A flat mergeAll of the whole app is hard to navigate and reason about. Compose per-domain modules and merge those.
instead of Layer.mergeAll(A, B, C, D, E, F, G, H, I, J, K, L)
write const CoreInfra = Layer.mergeAll(A, B, C)
const UserModule = Layer.mergeAll(D, E)
Layer.mergeAll(CoreInfra, UserModule, ...)
`yield* Effect.gen(...)` directly inside another generator adds a wrapper for nothing — inline the inner generator's body into the parent.
instead of Effect.gen(function* () {
const user = yield* Effect.gen(function* () {
return yield* repo.byId(id)
})
})
write Effect.gen(function* () {
const user = yield* repo.byId(id)
})
orDie converts every failure into an unrecoverable defect. Fine when failure truly is impossible — but if the error is expected (config, validation, IO), handle it with catchTag instead.
instead of loadConfig.pipe(Effect.orDie)
write loadConfig.pipe(Effect.catchTag("ConfigError", () => Effect.succeed(defaults)))
Returning a bare Effect from Effect.gen makes the success value an Effect (Effect<Effect<A>>) — usually you want `return yield*` so it actually runs.
instead of Effect.gen(function* () {
return Effect.succeed(1) // success value is an Effect!
})
write Effect.gen(function* () {
return yield* Effect.succeed(1)
})
`concurrency: "unbounded"` over a large collection spawns a fiber per element with no backpressure. Prefer a bounded number sized to the resource.
instead of Effect.forEach(users, notify, { concurrency: "unbounded" })
write Effect.forEach(users, notify, { concurrency: 10 })
Tagged errors are themselves yieldable: `return yield* new MyError({...})` — wrapping in Effect.fail is redundant.
instead of return yield* Effect.fail(new NotFound({ id }))
write return yield* new NotFound({ id })
An Effect.gen whose body is just `return yield* effect` adds a generator allocation for nothing — use the effect expression directly.
instead of Effect.gen(function* () {
return yield* fetchUser(id)
})
`.pipe()` with no arguments does nothing — remove it.
instead of const value = effect.pipe()
write const value = effect
Chained pipes (`x.pipe(a).pipe(b)` / `pipe(pipe(x, a), b)`) are one pipe — merge the steps.
instead of value.pipe(Effect.map(f)).pipe(Effect.flatMap(g))
write value.pipe(Effect.map(f), Effect.flatMap(g))
Promises can't be interrupted — but Effect hands tryPromise an AbortSignal. Pass it to signal-aware APIs (fetch, SDKs) so interruption actually cancels the work.
instead of Effect.tryPromise({
try: () => fetch(url),
catch: (cause) => new FetchError({ cause }),
})
write Effect.tryPromise({
try: (signal) => fetch(url, { signal }), // interruption cancels the request
catch: (cause) => new FetchError({ cause }),
})
Mapping to a constant has dedicated combinators: `Effect.asVoid` for undefined, `Effect.as(value)` for constants — clearer intent, no closure.
instead of effect.pipe(Effect.map(() => undefined))
write effect.pipe(Effect.asVoid)
A bare number for time is ambiguous (ms? s?). Duration strings ("2 seconds") or Duration.seconds(2) eliminate the guesswork.
instead of Effect.sleep(2000) // ms? s?
write Effect.sleep("2 seconds")
`Effect.succeed(undefined)` allocates a new effect for a constant — `Effect.void` is the canonical shared instance.
instead of Effect.succeed(undefined)
`Effect.map` followed by `Effect.flatten` is exactly `Effect.flatMap`.
instead of effect.pipe(Effect.map(toEffect), Effect.flatten)
write effect.pipe(Effect.flatMap(toEffect))
A loop of yields inside Effect.gen works, but Effect.forEach / Effect.all make concurrency explicit and tunable, and keep interruption semantics obvious.
instead of Effect.gen(function* () {
for (const id of ids) {
yield* processUser(id)
}
})
write Effect.forEach(ids, (id) => processUser(id), { concurrency: 5 })
// or { concurrency: 1 } to stay sequential — but explicit
flatMap inside flatMap nests like callbacks. Effect.gen turns the same sequencing into flat, readable statements: const a = yield* ...; const b = yield* ...
instead of getUser(id).pipe(
Effect.flatMap((user) =>
getAccount(user).pipe(
Effect.flatMap((account) => createInvoice(user, account))
)
)
)
write Effect.gen(function* () {
const user = yield* getUser(id)
const account = yield* getAccount(user)
return yield* createInvoice(user, account)
})
switch on `_tag` is non-exhaustive by default. Match.valueTags / Effect.catchTags give exhaustive, declarative dispatch.
instead of switch (event._tag) {
case "OrderPlaced": ...
case "OrderShipped": ...
}
write Match.valueTags(event, {
OrderPlaced: (e) => ...,
OrderShipped: (e) => ...,
}) // exhaustive
Effect's platform services (FileSystem, Path, Command) wrap these with typed errors, interruption, and test layers.
instead of import { readFileSync } from "node:fs"
write import { FileSystem } from "@effect/platform"
// const fs = yield* FileSystem.FileSystem; yield* fs.readFileString(path)
Unbounded queues have no backpressure — a fast producer grows the queue until OOM. Prefer Queue.bounded (or sliding/dropping) sized to the workload.
instead of const queue = yield* Queue.unbounded<Job>()
write const queue = yield* Queue.bounded<Job>(100) // backpressure
Raw JSON.parse returns `any` and throws. At data boundaries use Schema.fromJsonString / UnknownFromJsonString for typed, validated decoding.
instead of const data = JSON.parse(raw) // any, throws
write const data = yield* decodeUser(raw) // Schema.fromJsonString(User)
Interpolating JSON.stringify into a log message defeats structured logging. Log a stable message and attach data with Effect.annotateLogs.
instead of Effect.log(`Results: ${JSON.stringify(results)}`)
write Effect.log("results computed").pipe(Effect.annotateLogs({ results }))
Effect.sync(() => literal) adds a pointless thunk — Effect.succeed(literal) is direct.
instead of Effect.sync(() => 42)
The identifier argument defaults to the tag — repeating the same string is noise; drop it.
instead of class NotFound extends Schema.TaggedError<NotFound>("NotFound")("NotFound", {}) {}
write class NotFound extends Schema.TaggedError<NotFound>()("NotFound", {}) {}
Bare Effect.try/tryPromise puts UnknownException in the error channel. The { try, catch } form maps the failure to a typed domain error.
instead of Effect.tryPromise(() => fetch(url)) // UnknownException
write Effect.tryPromise({
try: () => fetch(url),
catch: (cause) => new FetchError({ cause }),
})
Retrying every failure indiscriminately retries validation errors, 404s, and bugs. Add `while`/`until` (or catchTag routing) so only transient failures are retried.
instead of fetchData.pipe(Effect.retry(Schedule.exponential("100 millis")))
write fetchData.pipe(Effect.retry({
schedule: Schedule.exponential("100 millis"),
while: (e) => e._tag === "TransientError",
}))
Stream.mapEffect runs items one at a time unless you pass { concurrency }. For I/O per element that's usually a large slowdown — make the choice explicit.
instead of stream.pipe(Stream.mapEffect(processItem))
write stream.pipe(Stream.mapEffect(processItem, { concurrency: 4 }))
effect/unstable/* APIs may break in minor releases — fine to use, worth tracking.
instead of import { HttpApi } from "effect/unstable/httpapi"
write // fine to use — unstable APIs may change in minor releases; pin exactly
No rules match — try a different search.