@aurelienbbn/oxlint-plugin-effect
v0.7.0
Published
Custom oxlint rules for Effect projects.
Maintainers
Readme
@aurelienbbn/oxlint-plugin-effect
42 oxlint rules for Effect code: runtime boundaries, failure channels, concurrency, services, Schema. Only what @effect/tsgo doesn't own.
pnpm add -D @aurelienbbn/oxlint-plugin-effect oxlint # oxlint >=1.82.0 <2.0.0{
"jsPlugins": ["@aurelienbbn/oxlint-plugin-effect"],
"rules": {
"effect/require-all-concurrency": "error",
"effect/require-tagged-effect-fail": "error",
"effect/no-run-promise-in-runtime": ["error", { "allow": ["**/src/main.ts"] }]
}
}No preset: enable each rule by name.
Upstream first, this plugin second
@effect/tsgo recommended oxlint preset ← enable FIRST, wins every overlap (type-aware)
floating Effects · missing yield* / return yield* · Effect-native globals & JSON
Effect.fn opportunities · try/catch and timers in generators · class Self mismatch
+
withEffectTsgoLayer extras ← @aurelienbbn/oxlint-config; off in recommended
any/unknown in error & requirements channels · unsafe Effect type assertions
deterministic service/error keys · Effect.provide outside entry points
│
▼
@aurelienbbn/oxlint-plugin-effect ← policies and syntactic failure modes tsgo doesn't ownWire tsgo with withEffectTsgoLayer from @aurelienbbn/oxlint-config: it adds the four extras and settles every overlap with this plugin in one table. Mind its version lock: @effect/tsgo 0.45.0 accepts only oxlint 1.81.0 / 1.82.0 and oxlint-tsgolint 7.0.2001.
| 13 rules removed in favor of tsgo | Owner |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| no-floating-effect, no-plain-yield, require-return-on-failure-yield, prefer-effect-fn, no-raw-json-parse, no-raw-json-stringify, no-ambient-nondeterminism | ✅ recommended |
| no-effect-type-assertion, no-unsafe-error-channel, matching-identifier | ⚠️ only with withEffectTsgoLayer (or tsgo's correctness + style presets) |
| no-nested-layer-provide, no-cascading-layer-provide, use-root-imports | ❌ no exact owner |
Type-dependent conclusions stay with @effect/tsgo. Ownership: docs/rule-ownership.md; the catalog gate enforces the inventory.
What fires
const values = Effect.all(programs); // ❌ require-all-concurrency
const values = Effect.all(programs, { concurrency: 1 }); // ✅ explicit choice
const program = Effect.fail("boom"); // ❌ require-tagged-effect-fail
const program = Effect.fail(new DomainError({ reason })); // ✅ tagged error
const load = (id: string) => Effect.fn(`users.load.${id}`)(body); // ❌ no-dynamic-span-name
const load = Effect.fn("users.load")(body); // ✅ ids go in span attributes
class NotFound extends Schema.TaggedError<NotFound>()("Missing", {}) {} // ❌ tagged-error-name, twice
class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", {}) {} // ✅ catchTag greps to the class
const Status = Schema.Literals(["Pending", "partially-refunded"]); // ❌ schema-literal-case, twice
const Status = Schema.Literals(["pending", "partially_refunded"]); // ✅ one case on the wire
const sync = Effect.fn("OrderSync.run")(body); // ❌ telemetry-name-format
const sync = Effect.fn("orders.sync")(body); // ✅ <area>.<operation>, lowercase snake_case
Effect.tryPromise(() => fetch(url)); // ❌ no-untyped-try-promise-catch, require-abort-signal
Effect.tryPromise({
try: (signal) => fetch(url, { signal }),
catch: (cause) => new HttpError({ cause }), // ✅ typed, cause kept, signal forwarded
});- Concurrency rules demand an explicit choice; they don't claim omitted concurrency is unbounded.
- Same
allowpaths onno-run-promise-in-runtimeandno-unscoped-runtime-launch: shared boundary matching. tagged-error-nameclashes with tsgodeterministic-keysonly when tsgo'skeyPatternsgain anerrortarget: that target wants a package-qualified tag (pkg/file/NotFoundError). Keep errors out ofkeyPatterns, or turn this rule off.schema-literal-casekeepsSchema.Literals([...])values in one case, snake_case by default: they travel as data (URLs, SQL, logs, wire contracts), and snake_case is what Postgres, Shopify REST (partially_refunded) and Stripe (requires_payment_method) use.Schema.Literal("..."),_tags and non-string literals are out of scope. No fix: renaming a wire value needs a coordinated change.telemetry-name-formatwants indexed names as<area>.<operation>in lowercase dotted snake_case (mcp.auth.verify_api_key): span names,Rpc.maketags, and the keys ofannotateLogs,annotateSpans,annotateCurrentSpanandwithSpan'sattributes(one segment allowed:event). String literals only;no-dynamic-span-nameowns runtime names.telemetry-name-formatwithlogMessages: truetreats a log message as an event name: the first argument ofEffect.log,logTrace,logDebug,logInfo,logWarning,logErrorandlogFatal(throughEffect, an alias, or a named import fromeffect/Effect) must be a literal in the name format, likeEffect.logInfo("webhook.rejected", { attempt }), so log backends can search and count by it. A variable, a template with values or a concatenation is reported too: values go in later arguments orEffect.annotateLogs.- Enable
telemetry-name-formatoreffect-fn-name-matches-binding, not both: the binding rule ties the last span segment to the binding (const syncAll = Effect.fn("orders.syncAll")), which snake_case rejects. - 🎨
dependencies-first,padding-after-dependencies,no-switch,prefer-match,prefer-effect-array-helpers,schema-type-adjacent,schema-literal-case,tagged-error-name,telemetry-name-formatare opinionated. Suppress at the exceptional call site, not in the shared config.
| Job | Rules |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🚨 failures (10) | no-catch-all-cause, no-effect-ordie, no-swallowed-failure, no-untyped-try-promise-catch, preserve-thrown-cause, no-unsafe-error-mapper, require-tagged-effect-fail, no-effect-promise, bounded-retry, tagged-error-name 🎨 |
| 🚪 runtime boundaries (7) | no-run-promise-in-runtime, no-unscoped-runtime-launch, prefer-run-main, no-fork-detach, no-managed-runtime-per-call, prefer-it-effect (opt-in, needs @effect/vitest), no-fake-timers-in-effect-tests |
| 🧬 bodies & tracing (7) | no-unsafe-effect-body, require-named-effect-fn, effect-fn-name-matches-binding, no-dynamic-span-name, telemetry-name-format 🎨, dependencies-first 🎨, padding-after-dependencies 🎨 |
| 🧩 services & layers (4) | no-service-constructor-imports, no-service-dependency-parameters, no-service-option, no-static-service-forwarders |
| 📐 Schema & config (4) | no-schema-any, schema-type-adjacent 🎨, schema-literal-case 🎨, require-redacted-secret-config |
| ⚡ concurrency (3) | require-all-concurrency, require-for-each-concurrency, require-abort-signal |
| 🎨 style (3) | no-switch, prefer-match, prefer-effect-array-helpers |
| 🔁 arrays (4) | no-array-callback-reference, no-array-for-each, no-array-method-this-argument, no-array-sort: unicorn's array checks, skipping Effect modules (Option.some, Effect.forEach, Arr.sort) |
tests = **/*.{test,spec}.{ts,tsx}.
| Rule | Option | Default |
| ---------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------- |
| no-run-promise-in-runtime | allow | tests, **/scripts/** |
| no-unscoped-runtime-launch | allow | tests, **/scripts/** |
| prefer-run-main | allow | tests, **/scripts/** |
| no-managed-runtime-per-call | allow | tests, **/scripts/** |
| prefer-effect-array-helpers | allow | tests, **/scripts/** |
| | ignoredObjects | Array, Arr, Effect, HashMap, HashSet, Match, Option, Record, Schedule, Schema, Stream |
| no-schema-any | allow | tests, **/fixtures/**, **/scripts/**, tools/** |
| no-effect-ordie | allow, allowedCalls | [], [] |
| no-effect-promise | allow | [] |
| | mode | "all" · or "rejectable-only" |
| no-fork-detach | allow | [] |
| no-swallowed-failure | allowInFinalizers | true |
| prefer-it-effect | testFiles | tests |
| require-abort-signal | abortableCalls | ["fetch"] |
| require-redacted-secret-config | secretPattern | (SECRET\|PASSWORD\|PASSWD\|TOKEN\|API_?KEY\|PRIVATE_?KEY\|CREDENTIAL\|DATABASE_URL\|_DSN$) |
| | benignPattern | _(TTL\|LENGTH\|NAME\|HEADER\|URL_PREFIX\|COUNT\|ENABLED)$ |
| effect-fn-name-matches-binding | ignorePattern | none |
| no-service-constructor-imports | serviceModules | none (project-local sources always count) |
| no-service-dependency-parameters | serviceTypeNames | [] |
| tagged-error-name | suffix | "Error" |
| schema-literal-case | case | "snake" · or "kebab", "camel", "pascal" |
| telemetry-name-format | pattern | ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ (names) |
| | minSegments | 2 (dot-separated segments a name needs) |
| | keyPattern | ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$ (annotation and attribute keys) |
| | logMessages | false; true holds the first argument of Effect.log* to the name format |
1 autofix, 5 suggestions
Only padding-after-dependencies has one behavior-preserving rewrite: it inserts the blank line below the leading service dependencies. Every other fix needs project knowledge: error type, runtime boundary, concurrency policy, layer owner, tracing name, platform adapter.
Effect.gen(function* () {
const repo = yield* UserRepo;
const user = yield* repo.findUser(id); // ❌ padding-after-dependencies
});
Effect.gen(function* () {
const repo = yield* UserRepo;
const user = yield* repo.findUser(id); // ✅ dependencies stand apart
});| Rule | Suggests |
| -------------------------------- | ------------------------------------------------------------- |
| require-abort-signal | forward the thunk's signal into fetch |
| effect-fn-name-matches-binding | rename the span to the binding name |
| no-fork-detach | forkChild → forkScoped inside a Layer constructor |
| no-swallowed-failure | add { log: true } to Effect.ignore |
| require-redacted-secret-config | Config.String / Config.NonEmptyString → Config.Redacted |
Migration
| Change | Why | Now |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| prefer-schema-decode-unknown removed | Effect 4's typed decoders (decodeSync, decodeEffect, …) take S["Encoded"], so as unknown input fails typecheck; decodeEither no longer exists | effecttsgo/prefer-schema-over-json reports JSON.parse, typescript/no-explicit-any reports as any. Drop the rule from your config |
| no-unsafe-effect-body keeps only throw | try/catch and global timers in generators are effecttsgo/try-catch-in-effect-gen and effecttsgo/global-timers-in-effect (both in recommended); Effect.gen rejects an async function* at typecheck | same rule name, no config change. ⚠️ try/finally around yield* without catch has no verified tsgo owner |
| no-catch-all-cause, no-swallowed-failure widened | tsgo's catch-to-ignore / catch-to-or-else-succeed rewrites led to Effect.ignoreCause and Effect.orElseSucceed(() => placeholder), which passed silently | Effect.ignoreCause, Effect.catchDefect report under no-catch-all-cause; a placeholder Effect.orElseSucceed under no-swallowed-failure |
Contract
| Topic | Contract |
| -------------- | --------------------------------------------------------------------------------- |
| Effect version | exercised on 4.0.0-rc.115. ⚠️ Effect 3/4 spellings not promised interchangeable |
| imports | namespace and aliased imports recognized; local shadows ignored |
| overlap | @effect/tsgo's type-aware diagnostics win; settled in withEffectTsgoLayer |
Registered contract inventory
Generated from package exports by pnpm catalog. Rule-specific options and limitations are described above and in the source tests.
| Rule/check | Trigger or review scope |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| bounded-retry | Require Effect.retry policies visible in the same file to carry a bound such as Schedule.recurs, Schedule.upTo, Schedule.during, times, while, or until. |
| dependencies-first | Yield service dependencies before runtime logic in Effect bodies. |
| effect-fn-name-matches-binding | Require the last dot-segment of an Effect.fn span name to equal the variable or property the function is bound to. |
| no-array-callback-reference | Disallow passing a function reference to an array method's callback, skipping calls on Effect modules that are not arrays. |
| no-array-for-each | Disallow Array#forEach in favor of for…of, skipping Effect's forEach helpers (Effect.forEach). |
| no-array-method-this-argument | Disallow the thisArg of array methods, skipping Effect's data-first helpers (Arr.filter(xs, f)), whose second argument is the callback. |
| no-array-sort | Disallow Array#sort, which mutates, in favor of toSorted, skipping Effect's sort helpers (Arr.sort), which do not. |
| no-catch-all-cause | Disallow Effect.catchCause, catchCauseIf, catchCauseFilter, catchDefect, ignoreCause, sandbox, Layer.catchCause, and Effect 3 catchAllCause because they catch defects. |
| no-dynamic-span-name | Disallow template literals with runtime values and string concatenation as span names in Effect.fn, Effect.withSpan, withSpanScoped, useSpan, makeSpan, makeSpanScoped, Layer.withSpan, and Stream.withSpan. |
| no-effect-ordie | Disallow Effect.orDie, Effect.orDieWith, Layer.orDie, and Effect.catch handlers that only die outside configured escape hatches. |
| no-effect-promise | Disallow Effect.promise outside configured files unless the thunk is a syntactically total promise such as Promise.resolve or a resolve-only timer. |
| no-fake-timers-in-effect-tests | Disallow vi.useFakeTimers, vi.advanceTimers*, vi.runTimers, and vi.setSystemTime in files importing @effect/vitest; use TestClock. |
| no-fork-detach | Disallow Effect.forkDetach outside configured files and Effect.forkChild directly inside Layer constructors, where Effect.forkScoped ties the fiber to the layer scope. |
| no-managed-runtime-per-call | Disallow ManagedRuntime.make inside function bodies outside configured files; build the runtime once at module scope. |
| no-run-promise-in-runtime | Disallow Effect.runPromise, runPromiseExit, and their runWith variants outside configured runtime boundaries. |
| no-schema-any | Disallow Schema.Any outside configured escape-hatch files. |
| no-service-constructor-imports | Disallow make-prefixed imports from project service directories into runtime code. |
| no-service-dependency-parameters | Disallow service projection types and configured service names in parameters. |
| no-service-option | Disallow Effect.serviceOption in favor of required services provided by layers. |
| no-static-service-forwarders | Disallow static class properties that only forward to an Effect service method. |
| no-swallowed-failure | Disallow Effect.ignore without a log option, and Effect.catch handlers or Effect.orElseSucceed fallbacks that discard the error for a placeholder. |
| no-switch | Disallow switch statements in Effect code in favor of Match. |
| no-unsafe-effect-body | Disallow throw inside Effect.gen, Effect.fn, and Effect.fnUntraced bodies. |
| no-unsafe-error-mapper | Disallow unknown and any in Effect error mapper parameters. |
| no-unscoped-runtime-launch | Disallow Effect.runFork, runSync, runSyncExit, runCallback, their run*With variants, and Layer.launch outside configured runtime boundaries. |
| no-untyped-try-promise-catch | Require Effect.try and Effect.tryPromise to map thrown or rejected values with a catch handler. |
| padding-after-dependencies | Require a blank line after the leading service dependencies of an Effect.gen, Effect.fn, or Effect.fnUntraced generator body when logic follows them. |
| prefer-effect-array-helpers | Prefer Effect array helpers over native array helper methods. |
| prefer-it-effect | Prefer it.effect from @effect/vitest over Effect.runPromise or Effect.runSync inside plain it/test callbacks in test files (opt-in; requires @effect/vitest). |
| prefer-match | Prefer Match from effect over chained literal ternaries. |
| prefer-run-main | Prefer NodeRuntime.runMain or BunRuntime.runMain over a module top-level Effect.runPromise or Effect.runFork statement. |
| preserve-thrown-cause | Require Effect.try and Effect.tryPromise catch mappers to use the thrown value they receive. |
| require-abort-signal | Require Effect.tryPromise and Effect.promise thunks that call fetch (or configured abortable calls) to accept the AbortSignal parameter and pass it on. |
| require-all-concurrency | Require explicit concurrency for Effect.all. |
| require-for-each-concurrency | Require explicit concurrency for Effect.forEach. |
| require-named-effect-fn | Require Effect.fn calls to include a non-empty name. |
| require-redacted-secret-config | Require Config.Redacted instead of Config.String or Config.NonEmptyString for configuration keys whose name looks like a secret. |
| require-tagged-effect-fail | Require tagged error values for Effect.fail and Effect.failSync, rejecting literals, native Errors, and same-file untagged Error subclasses. |
| schema-literal-case | Require the string values of Schema.Literals([...]) to follow one case: snake_case by default, or kebab, camel, or pascal. |
| schema-type-adjacent | Keep a Schema's matching type alias adjacent, allowing whitespace and JSDoc. |
| tagged-error-name | Require classes extending Schema.TaggedError, Schema.TaggedErrorClass, or Data.TaggedError to end with the error suffix and to use their class name as the literal _tag. |
| telemetry-name-format | Require literal span names, Rpc.make tags and (with logMessages) Effect log event names in lowercase dotted snake_case with at least two segments, and annotation keys in lowercase dotted snake_case. |
Credited concepts
- Effect bundled AGENTS.md "The name string should match the function name" (concept)
- Effect bundled ai-docs "capped exponential backoff with jitter and max attempts" pattern (concept)
- Effect bundled ai-docs
04_integration/10_managed-runtime.tsmodule-level runtime (concept) - Effect bundled ai-docs
09_testing/10_effect-tests.ts"controls time with TestClock" (concept) - Effect bundled ai-docs
catch: (cause) => new X({ cause })convention (concept) - OpenTelemetry Tracing API specification, span name guidance "most general string ... low cardinality" (Apache-2.0, concept)
- ai-automation by Sandro Maglione (inspiration, independently re-implemented)
- anti-slop by Dillon Mulroy (MIT, concept re-implemented)
- eslint-plugin-unicorn
no-array-callback-reference(MIT; rule concept, independently re-implemented) - eslint-plugin-unicorn
no-array-for-each(MIT; rule concept, independently re-implemented) - eslint-plugin-unicorn
no-array-method-this-argument(MIT; rule concept, independently re-implemented) - eslint-plugin-unicorn
no-array-sort(MIT; rule concept, independently re-implemented) - executor by Rhys Sullivan, dotted snake_case span and attribute names such as
mcp.auth.verify_api_key(MIT, naming-style inspiration)
