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