effect doctor

Scan your Effect TS codebase, get a score, fix what matters. 89 rules, every one with the cleaner rewrite. Rust-fast — thousands of files in milliseconds.

npx effect-doctor # or: effect-doctor --scope lines  (just your PR)
21 errors 28 warnings 40 info

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" })
}

no-run-inside-effect

error Correctness

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

no-runsync-on-async-effect

error Correctness

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)))

no-throw-in-effect

error Correctness

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 })
})

no-try-catch-in-gen

error Correctness

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))
)

require-yield-star

error Correctness

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)

schema-class-self-mismatch

error Correctness

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-catch-renames

error v4 Migration

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-cause-flattened

error v4 Migration

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-context-service

error v4 Migration

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-fiberref-removed

error v4 Migration

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-fork-renames

error v4 Migration

v4 renamed forking: fork→forkChild, forkDaemon→forkDetach; forkAll and forkWithErrorHandler were removed.

✖ instead of
yield* Effect.fork(task)
✓ write
yield* Effect.forkChild(task)

v4-gen-self-options

error v4 Migration

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-layer-scoped-to-effect

error v4 Migration

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-option-renames

error v4 Migration

v4 renamed Option.fromNullable to Option.fromNullishOr.

✖ instead of
Option.fromNullable(value)
✓ write
Option.fromNullishOr(value)

v4-package-consolidation

error v4 Migration

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-runtime-removed

error v4 Migration

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-schema-renames

error v4 Migration

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-scope-provide

error v4 Migration

v4 renamed Scope.extend to Scope.provide.

✖ instead of
Scope.extend(effect, scope)
✓ write
Scope.provide(effect, scope)

adopt-await-in-loop

warn Effect Adoption

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 })

no-catchall-to-null

warn Correctness

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))
)

no-chained-provides

warn Architecture

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)))

no-effect-fn-iife

warn Idiomatic

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

no-manual-sql-transactions

warn Architecture

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)

no-map-returning-effect

warn Correctness

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))

no-object-literal-comparison

warn Correctness

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" })))

no-promise-all-in-effect

warn Correctness

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,
})

no-string-errors

warn Idiomatic

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 }))

no-tag-string-comparison

warn Idiomatic

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)) { ... }

no-then-in-sync

warn Correctness

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))

no-try-finally-in-gen

warn Correctness

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

prefer-catch-tag

warn Idiomatic

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))

prefer-clock-service

warn Idiomatic

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
})

prefer-config-module

warn Idiomatic

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")
})

prefer-config-redacted

warn Correctness

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")

prefer-decode-effect

warn Idiomatic

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)
})

prefer-effect-fn

warn Idiomatic

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)
})

prefer-effect-logging

warn Idiomatic

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 }))
})

prefer-effect-timers

warn Idiomatic

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)

prefer-it-effect

warn Idiomatic

@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)

prefer-platform-fetch

warn Idiomatic

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

prefer-random-service

warn Idiomatic

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())
✓ write
Random.next

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"))

v4-no-gen-adapter

warn v4 Migration

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)
})

add-jitter-to-backoff

info Performance

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)

adopt-async-function

info Effect Adoption

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())
})

adopt-new-promise

info Effect Adoption

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 }))))
})

adopt-promise-all

info Effect Adoption

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 })

adopt-promise-chain

info Effect Adoption

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

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))
})

cap-exponential-backoff

info Performance

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"))
)

catch-to-map-error

info Idiomatic

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" })

hoist-schema-codecs

info Performance

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)

meaningful-span-names

info Idiomatic

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* () { ... })

no-eager-chunk-stream

info Performance

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)

no-effect-do-notation

info Idiomatic

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 }
})

no-layer-mergeall-megalist

info Architecture

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, ...)

no-nested-gen-yield

info Idiomatic

`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)
})

no-orDie-to-silence-errors

info Correctness

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)))

no-return-effect-in-gen

info Idiomatic

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)
})

no-unbounded-concurrency

info Performance

`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 })

no-unnecessary-gen

info Idiomatic

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)
})
✓ write
fetchUser(id)

no-unnecessary-pipe

info Idiomatic

`.pipe()` with no arguments does nothing — remove it.

✖ instead of
const value = effect.pipe()
✓ write
const value = effect

no-unnecessary-pipe-chain

info Idiomatic

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 }),
})

prefer-as-void

info Idiomatic

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")

prefer-effect-void

info Idiomatic

`Effect.succeed(undefined)` allocates a new effect for a constant — `Effect.void` is the canonical shared instance.

✖ instead of
Effect.succeed(undefined)
✓ write
Effect.void

`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)

prefer-queue-bounded

info Performance

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

prefer-schema-over-json

info Idiomatic

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)
✓ write
Effect.succeed(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 }),
})

retry-only-retryable

info Architecture

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 }))

v4-unstable-import-awareness

info v4 Migration

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