@enfect/oxlint

中文文档
0.10.0发布于 2026/11/02MITTypeScriptES Module近 30 天下载 5950

Oxlint plugin enforcing Effect-first development conventions

原始使用文档
文档目录67 节
  1. Installation
  2. Rules at a glance
  3. Anti-slop rules
  4. Replacing an anti-slop installation
  5. Tailwind rules
  6. Effect-first rules
  7. avoid-any
  8. avoid-data-tagged-error
  9. avoid-direct-json
  10. avoid-direct-tag-checks
  11. avoid-expect-in-if
  12. avoid-mutable-state
  13. avoid-native-fetch
  14. avoid-native-object-helpers
  15. avoid-node-imports
  16. avoid-non-null-assertion
  17. avoid-object-type
  18. avoid-option-getorthrow
  19. avoid-platform-coupling
  20. avoid-process-env
  21. avoid-react-hooks
  22. avoid-schema-suffix
  23. avoid-sync-fs
  24. avoid-try-catch
  25. avoid-ts-ignore
  26. avoid-untagged-errors
  27. avoid-yield-ref
  28. casting-awareness
  29. context-tag-extends
  30. effect-catchall-default
  31. effect-promise-vs-trypromise
  32. effect-run-in-body
  33. imperative-loops
  34. no-barrel-imports
  35. no-opaque-instance-fields
  36. prefer-arr-match
  37. prefer-arr-sort
  38. prefer-duration-constructors
  39. prefer-effect-fn
  40. prefer-effect-is
  41. prefer-match-over-switch
  42. prefer-namespace-imports
  43. prefer-option-over-null
  44. prefer-redacted-config
  45. require-effect-concurrency
  46. require-filter-metadata
  47. require-schema-type-alias
  48. throw-in-effect-gen
  49. use-clock-service
  50. use-command-executor-service
  51. use-console-service
  52. use-filesystem-service
  53. use-http-client-service
  54. use-path-service
  55. use-random-service
  56. use-temp-file-scoped
  57. vm-in-wrong-file
  58. yield-in-for-loop
  59. maybe-prefix-requires-option
  60. no-effect-ignore-then-as
  61. no-length-comparison
  62. prefer-array-fromoption-over-option-match-empty
  63. require-is-prefix-for-boolean-schema-field
  64. no-cause-in-http-response
  65. Suppression
  66. Development
  67. License

@enfect/oxlint

npm License: MIT

An opinionated oxlint plugin for Effect v4 that drives every module toward Effect services, typed error channels, and functional composition. It flags imperative patterns, raw Node APIs, untyped errors, and other non-idiomatic shapes at the lint layer so they never make it into review.

The plugin ships 74 rules namespaced under effect/, including all 16 rules from dmmulroy/anti-slop. Rules are implemented with the effect-oxlint SDK and run as standard oxlint custom rules.

The runtime and recommendations target Effect 4.1.0 (stable). Area imports use effect/http, effect/http-api, effect/process, etc.; the old unstable paths are removed. APIs marked @stability unstable can still change in minor releases. Keep directly used Effect-family packages on the same release version.

The published plugin is checked with oxlint 1.85. It is part of Enfect, which also publishes Effect plugins for Claude Code and OpenCode.

Installation

npm install --save-dev oxlint @enfect/oxlint
# or
bun add -D oxlint @enfect/oxlint

The TypeScript sources are also published to JSR as @enfect/oxlint.

Use the generated recommended config from oxlint.config.ts:

import { defineConfig } from 'oxlint';
import effect from '@enfect/oxlint';

export default defineConfig({
    extends: [effect.configs.recommended]
});

configs.recommended registers the package through oxlint's jsPlugins field and enables all 74 rules at error severity. configs.all also includes every rule.

To override an individual rule, add a rules entry after the extends block:

import { defineConfig } from 'oxlint';
import effect from '@enfect/oxlint';

export default defineConfig({
    extends: [effect.configs.recommended],
    rules: {
        'effect/avoid-native-object-helpers': 'off',
        'effect/avoid-direct-json': 'warn'
    }
});

If you use .oxlintrc.json, oxlint cannot import a package config object. Configure the JS plugin and any rules you want explicitly:

{
    "jsPlugins": ["@enfect/oxlint"],
    "rules": {
        "effect/avoid-direct-json": "error"
    }
}

Use oxlint.config.ts when you want the full generated recommended config.

Rules at a glance

Anti-slop rules

These rules apply to JavaScript and TypeScript independently of Effect imports, except for the architecture policy expressed by no-service-constructor-imports. They are part of the same effect/ namespace and both generated presets.

RuleWhat it catches / exceptions
no-chained-type-assertionsNested as or angle-bracket assertions, including parenthesized chains. Chains made only of as const are allowed.
no-conditional-empty-object-spreadObject spreads with a conditional empty-object branch. No autofix: omitting a property is different from assigning undefined.
no-known-value-wideningKnown expressions flowing into explicit unknown, object, anonymous-object, or open-dictionary targets, including known arguments passed to local unknown type predicates. Empty dictionary accumulators and finite-key Record targets are allowed.
no-module-mockingVitest/Jest mock, doMock, and unstable_mockModule calls, including aliased imports and computed method names. Locally shadowed framework names are allowed.
no-object-parametersobject inputs, unions containing it, and scoped/transparent generic aliases resolving to it.
no-reflect-applyGlobal Reflect.apply, including computed access. Locally shadowed Reflect bindings are allowed.
no-reflect-getGlobal Reflect.get, including computed access. Locally shadowed Reflect bindings are allowed.
no-runtime-typeofRuntime typeof narrowing. Comparisons with the string "undefined" are allowed; type-guard checks can be enabled with an option. Type-level typeof is unaffected.
no-service-constructor-importsNamed make<CapitalizedName> imports from ./ or ../ modules outside *.test.* and *.spec.*. Package/path-alias imports, default imports, and static constructors are outside its scope.
no-shape-in-symbol-namesCase-insensitive shape in locally owned identifiers, including private and JSX names. Static member access such as schema.shape is allowed.
no-unknown-parametersExplicit unknown parameters and unions containing it. The parameter named cause and the exact subject of a type predicate are allowed.
no-unknown-returnsExplicit return contracts resolving to unknown, Promise<unknown>, or PromiseLike<unknown>, including scoped/transparent generic aliases.
no-unknown-type-aliasesScoped/transparent generic aliases resolving to unknown.
no-unsafe-dictionary-typeDictionary values based on unknown, any, object, {}, or semantic equivalents. Generic constraints such as T extends Record<string, unknown> are allowed.
no-widen-then-assertImmutable local flows that widen known evidence to unknown, any, object, or a broad record, then assert it back to a narrower type.
require-safety-comment-for-type-assertionNon-const assertions without a preceding, non-empty invariant justification. The default marker is SAFETY:.

The analysis uses Oxlint's ESTree and lexical scopes, not a TypeScript type checker. Same-file aliases, forward references, lexical shadowing, and transparent generic aliases are resolved where documented. Imported types and cross-file call signatures are not inferred. no-widen-then-assert deliberately limits its value-flow analysis to immutable bindings within a function boundary.

Replacing an anti-slop installation

Remove the old anti-slop and anti-slop-effect registrations from jsPlugins. Replace both rule-name prefixes with effect/, keeping existing severity and option values. Register @enfect/oxlint once, or use the recommended config above to enable the entire personal ruleset. There is no runtime dependency on anti-slop and no separate Effect entrypoint to register.

For example, an existing customized configuration becomes:

import { defineConfig } from 'oxlint';
import effect from '@enfect/oxlint';

export default defineConfig({
    extends: [effect.configs.recommended],
    rules: {
        'effect/no-runtime-typeof': ['error', { allowInTypeGuards: true }],
        'effect/require-safety-comment-for-type-assertion': [
            'error',
            { markers: ['INVARIANT', 'SAFETY'] }
        ]
    }
});

allowInTypeGuards defaults to false and permits checks directly within type predicate/assertion functions, not nested ordinary functions. markers defaults to ['SAFETY']; markers are literal strings (regex punctuation is escaped), trimmed before matching, and must be followed by : and a non-empty justification. Comments before exported declarations are recognized; comments after an assertion do not justify it.

// Rejected: assertions fabricate evidence in two steps.
const user = input as unknown as User;

// Rejected: the known "start" key is erased.
const handlers: Record<string, Handler> = { start: startHandler };

// Preserve known keys while checking their values.
const preciseHandlers = { start: startHandler } satisfies Record<string, Handler>;

// Accepted by require-safety-comment-for-type-assertion:
// SAFETY: parseUserId checked the identifier before branding it.
const userId = parsedId as UserId;

Some existing rules are intentionally stricter or overlap. casting-awareness still reports assertions even with a safety comment; turn it off if justified assertions are your chosen policy. prefer-effect-is can still report typeof inside guards even when no-runtime-typeof allows them. avoid-any overlaps with chained assertions, and avoid-object-type addresses Object/{} rather than the lowercase object parameter contract. Configure each independently.

Tailwind rules

RuleWhat it catches / exceptions
no-unsupported-tailwind-spacingTailwind v4 spacing classes in string and template literals whose numeric value is not a canonical multiple of 0.25, such as gap-1.125 or p-1.50. Tailwind generates no CSS for them, so they silently do nothing. The message suggests an equivalent arbitrary value at the default 0.25rem step. Arbitrary values, fractions, and tokens glued to a template interpolation are allowed.

Covered utilities: padding, margin, and their scroll variants; gap; space-x/space-y; w, h, size, and their min-/max- forms; inset and the top/right/bottom/left/start/end offsets; translate-x/-y/-z; indent; basis; leading; and border-spacing. Variant prefixes, negative values, and leading or trailing ! are recognized.

Effect-first rules

RuleWhat it catches
avoid-anyas any and as unknown as T casts
avoid-data-tagged-errorData.TaggedError — use Schema.TaggedError
avoid-direct-jsonJSON.parse / JSON.stringify — use Schema.fromJsonString
avoid-direct-tag-checksx._tag === "..." checks — use $is / $match / Match
avoid-expect-in-ifexpect(...) nested inside if blocks in tests
avoid-mutable-statelet bindings inside service / layer factories
avoid-native-fetchNative fetch() — use Effect HttpClient
avoid-native-object-helpersObject.keys / Object.entries / Object.fromEntries etc.
avoid-node-importsBare node:* imports in platform-agnostic code
avoid-non-null-assertionThe ! non-null assertion operator
avoid-object-typeObject and {} as types
avoid-option-getorthrow.getOrThrow on Option / Either / Result
avoid-platform-coupling@effect/platform-bun imports in binding packages
avoid-process-envprocess.env — use a Config service
avoid-react-hooksuseState / useEffect / useReducer — use Effect Atom VMs
avoid-schema-suffixSchema constants suffixed with Schema
avoid-sync-fsfs.readFileSync and other synchronous fs calls
avoid-try-catchtry / catch in Effect code
avoid-ts-ignore@ts-ignore / @ts-expect-error comments
avoid-untagged-errorsnew Error(...) and instanceof Error for recoverable failures
avoid-yield-refyield* ref / yield* deferred / yield* fiber (removed in v4)
casting-awarenessas T assertions (excluding as const / as never)
context-tag-extendsContext.Tag / Context.GenericTag / Effect.Service / legacy ServiceMap.*
effect-catchall-defaultBlanket Effect.catch / catchCause swallowing all errors
effect-promise-vs-trypromiseEffect.promise — prefer Effect.tryPromise
effect-run-in-bodyEffect.runSync / runPromise / runFork outside entrypoints
imperative-loopsfor / while / do…while in domain code
maybe-prefix-requires-optionmaybe* named field that is not an Option<T>
no-barrel-importsNamed imports from the effect barrel package
no-cause-in-http-responseCause.pretty/Cause.squash/caught cause or error flowing into a response body
no-effect-ignore-then-asRedundant Effect.ignore before Effect.as, or on known infallible primitives
no-length-comparison.length === 0 and friends — use named string/array predicates
no-opaque-instance-fieldsInstance members on Schema.Opaque classes
prefer-arr-matchManual empty / non-empty branching — use Arr.match
prefer-arr-sortArray.prototype.sort — use Arr.sort with an Order
prefer-array-fromoption-over-option-match-emptyOption.match that produces [] / [v]
prefer-duration-constructorsRaw millisecond numbers passed to Effect timing APIs
prefer-effect-fnEffect.gen bound to a const or used in a service method
prefer-effect-istypeof x === "string" — use P.isString and friends
prefer-match-over-switchswitch statements — use Match.value
prefer-namespace-importsNamed imports from Effect submodules — use namespace imports
prefer-option-over-nullT | null / T | undefined union types
prefer-redacted-configConfig.String("apiKey") etc. for secret-looking keys
require-effect-concurrencyEffect.all / forEach / validate without explicit concurrency
require-filter-metadataSchema.makeFilter / makeFilterGroup missing identifier / title / description
require-is-prefix-for-boolean-schema-fieldSchema.Boolean fields without a boolean predicate prefix
require-schema-type-aliasExported schema constant without a matching export type alias
throw-in-effect-genthrow inside Effect.gen / Effect.fn / Effect.fnUntraced
use-clock-servicenew Date() / Date.now() / Date.UTC() — use Clock / DateTime
use-command-executor-servicechild_process / node:child_process imports
use-console-serviceconsole.* — use Effect.log* / Console
use-filesystem-servicefs / node:fs / fs/promises imports
use-http-client-servicehttp / https / node:http / node:https imports
use-path-servicepath / node:path imports
use-random-serviceMath.random() — use the Random service
use-temp-file-scopedos.tmpdir() / unscoped makeTempFile / makeTempDirectory
vm-in-wrong-fileView Model interfaces and layers outside .vm.ts files
yield-in-for-loopyield* inside for loops — use Effect.forEach

avoid-any

as any and as unknown as T casts erase type safety. Validate unknown data with Schema.decodeUnknown*, preserve types with generics, or fix the upstream type.

// ❌
const user = data as any;
const config = JSON.parse(raw) as unknown as Config;

// ✅
const user = yield* Schema.decodeUnknownEffect(User)(data);
const config = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(AppConfig))(raw);

avoid-data-tagged-error

Data.TaggedError does not integrate with the Schema encode / decode pipeline. Use Schema.TaggedError for public errors that need decoding, encoding, or RPC transport.

// ❌
class NotFound extends Data.TaggedError('NotFound')<{ id: string }> {}
import * as Schema from 'effect/Schema';

class NotFound extends Schema.TaggedError<NotFound>()(
    'NotFound',
    { id: Schema.String }
) {}

avoid-direct-json

JSON.parse produces any; JSON.stringify does not validate a domain contract. Use Schema.fromJsonString(MySchema) at typed boundaries or Schema.fromJsonString(Schema.Unknown) for unknown payloads.

// ❌
const user: User = JSON.parse(raw);
const body = JSON.stringify(payload);
import * as Schema from 'effect/Schema';

class User extends Schema.Class<User>('User')({ id: Schema.String }) {}
const decodeUser = Schema.decodeUnknownEffect(Schema.fromJsonString(User));
const encodeUser = Schema.encodeEffect(Schema.fromJsonString(User));
const decodeUnknownJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));

avoid-direct-tag-checks

Reading _tag directly couples call sites to the discriminant string. Use the auto-generated $is / $match helpers or Match.value so renaming a variant is a typed refactor.

// ❌
if (result._tag === 'Success') return result.value;
switch (msg._tag) {
    case 'Loaded':
        /* ... */
}

// ✅
if (Result.isSuccess(result)) return result.success;
return Match.value(msg).pipe(
    Match.tag('Loaded', (m) => /* ... */),
    Match.exhaustive
);

avoid-expect-in-if

expect(...) nested inside an if block silently passes when the condition is false. Narrow first, then assert.

// ❌
if (result) {
    expect(result.id).toBe('abc');
}

// ✅
expect(result).toBeDefined();
expect(result.id).toBe('abc');

avoid-mutable-state

let bindings inside service or layer factories hide fiber-visible state and lifecycle behavior. Use Ref, SynchronizedRef, or Effect.cached so concurrent access is explicit. let inside pure helpers and narrow scopes is fine.

// ❌
export const CounterLive = Layer.effect(
    Counter,
    Effect.gen(function* () {
        let count = 0;
        return Counter.of({ inc: () => Effect.sync(() => count++) });
    })
);

// ✅
export const CounterLive = Layer.effect(
    Counter,
    Effect.gen(function* () {
        const count = yield* Ref.make(0);
        return Counter.of({ inc: () => Ref.update(count, (n) => n + 1) });
    })
);

avoid-native-fetch

Native fetch() returns a Promise<Response> with untyped errors. Effect's HttpClient gives you typed errors, request / response schemas, and testable layer substitution.

// ❌
const res = await fetch('/api/users');
const users = await res.json();

// ✅
const client = yield* HttpClient.HttpClient;
const users =
    yield* client
        .get('/api/users')
        .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(UserList)));

avoid-native-object-helpers

Object.keys returns string[] (not keyof T); Object.entries loses value types. Use the effect/Record helpers for type-safe equivalents.

// ❌
const keys = Object.keys(user);
const entries = Object.entries(config);
const obj = Object.fromEntries(pairs);

// ✅
import * as R from 'effect/Record';
const keys = R.keys(user);
const entries = R.toEntries(config);
const obj = R.fromEntries(pairs);

avoid-node-imports

node:* imports tie domain code to a single runtime. Use abstract services from effect and provide a platform implementation at the runtime boundary. Dedicated rules cover the most common cases (use-filesystem-service, use-path-service, use-command-executor-service, use-http-client-service); this rule is the catch-all.

// ❌
import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';

// ✅  Provide the platform layer at the runtime boundary
import { Digest, SecureRandom } from 'effect/crypto';
import * as Stream from 'effect/Stream';

avoid-non-null-assertion

! tells the compiler "trust me" and crashes at runtime when wrong. Model absence with Option, decode unknown shapes via Schema.decodeUnknown*, or guard at the boundary with ?. / ?? / Option.fromNullishOr.

// ❌
const name = user!.profile!.displayName!;

// ✅
const name = Option.fromNullishOr(user).pipe(
    Option.flatMapNullishOr((u) => u.profile?.displayName),
    Option.getOrElse(() => 'Anonymous')
);

avoid-object-type

Object provides no type safety, and {} matches any non-nullish value (including 42 and "hi"). Use a specific interface, Record<string, unknown>, or a Schema.Class.

// ❌
function merge(a: object, b: {}): object { ... }

// ✅
function merge<A extends Record<string, unknown>>(a: A, b: Partial<A>): A { ... }

avoid-option-getorthrow

.getOrThrow defeats the point of Option / Either / Result by throwing where the type promised a total handler. Use match, getOrElse, or map.

// ❌
const value = Option.getOrThrow(maybeUser);

// ✅
const value = Option.match(maybeUser, {
    onNone: () => defaultUser,
    onSome: (u) => u
});

avoid-platform-coupling

Packages under packages/*/binding/ wrap external systems using abstract services. This rule rejects concrete Bun platform imports there; provide platform layers at the application's runtime entry point.

// ❌  packages/myapp/binding/index.ts
import { BunHttpServer } from '@effect/platform-bun';

// ✅  packages/myapp/src/main.ts
import { BunHttpServer } from '@effect/platform-bun';

avoid-process-env

process.env is untyped, untested, and global. Config.* builds typed, layered, redactable configuration with default values and validation.

// ❌
const apiKey = process.env.API_KEY!;
const port = parseInt(process.env.PORT ?? '3000');

// ✅
const apiKey = yield* Config.Redacted('API_KEY');
const port = yield* Config.Int('PORT').pipe(Config.withDefault(3000));

avoid-react-hooks

React hooks scatter state, effects, and rendering across a single component. VMs with Effect Atom keep state in atoms, effects in actions, and components as pure renderers.

// ❌
function Profile({ id }: Props) {
    const [user, setUser] = useState<User>();
    useEffect(() => { fetchUser(id).then(setUser); }, [id]);
    return <div>{user?.name}</div>;
}

// ✅
// profile.vm.ts
export const userAtom = Atom.family((id: string) =>
    Atom.fn(Effect.fn('fetchUser')(function* () { ... }))
);

// profile.tsx
function Profile({ id }: Props) {
    const user = useAtomValue(userAtom(id));
    return <div>{user.name}</div>;
}

avoid-schema-suffix

Schema constants represent a domain type, not "a schema for a type." Name them after the concept (User) rather than the construction (UserSchema) — this matches how Schema.Class is named and keeps types and instances grep-symmetric.

// ❌
const UserSchema = Schema.Struct({ id: Schema.String });

// ✅
const User = Schema.Struct({ id: Schema.String });
export type User = typeof User.Type;

avoid-sync-fs

Synchronous fs calls block the event loop. Use FileSystem from effect/FileSystem for async, composable, testable file I/O.

// ❌
const text = fs.readFileSync(path, 'utf8');
fs.writeFileSync(path, data);

// ✅
const fs = yield* FileSystem.FileSystem;
const text = yield* fs.readFileString(path);
yield* fs.writeFileString(path, data);

avoid-try-catch

try / catch does not expose failures in a function's return type. Use Effect.try or Effect.tryPromise with Schema.TaggedError to keep expected errors in the typed channel.

// ❌
try {
    return JSON.parse(raw);
} catch (e) {
    return null;
}

// ✅
return (
    yield* Effect.try({
        try: () => externalParser(raw),
        catch: (cause) => new ParseFailed({ cause })
    })
);

avoid-ts-ignore

@ts-ignore and @ts-expect-error mask real bugs and silently rot when the underlying type changes. Fix the type at the source instead.

// ❌
// @ts-ignore
const result = someApi.experimental.method();

// ✅
// Augment the third-party type or wrap in a typed adapter
declare module 'some-api' {
    interface ExperimentalApi {
        method(): Result;
    }
}

avoid-untagged-errors

Schema.TaggedError gives each domain failure mode a tag that catchTag / catchTags can discriminate at the type level. Use Schema.isSchemaError for decoder failures; schema constructors such as makeEffect fail directly with SchemaIssue.Issue.

// ❌
throw new Error('User not found');
if (err instanceof Error) return null;

// ✅
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
    'UserNotFound', { id: Schema.String }
) {}

yield* Effect.fail(new UserNotFound({ id }));
yield* effect.pipe(Effect.catchTag('UserNotFound', () => Effect.succeed(null)));

avoid-yield-ref

Direct yield* ref / yield* deferred / yield* fiber / yield* latch was removed in Effect v4. Use the explicit method calls.

// ❌
const value = yield* counter;
const result = yield* deferred;

// ✅
const value = yield* Ref.get(counter);
const result = yield* Deferred.await(deferred);
const exit = yield* Fiber.join(fiber);
yield* Latch.await(latch);

casting-awareness

Every as T assertion is a checkpoint: is the cast redundant? Can generics or Schema.decodeUnknownEffect replace it? Does the upstream type need fixing? The rule exempts as const and as never; that exemption does not make an unchecked assertion sound.

// ❌
const items = (data as Array<User>).filter((u) => u.active);

// ✅
const items =
    yield* Schema.decodeUnknownEffect(Schema.Array(User))(data).pipe(
        Effect.map(Arr.filter((u) => u.active))
    );

// ✅  as const is fine
const STATUSES = ['Pending', 'Active', 'Closed'] as const;

context-tag-extends

class FooTag extends Context.Tag(...), Context.GenericTag, Effect.Service, and the legacy ServiceMap.* aliases were all removed or superseded in Effect v4. Define services with Context.Service and name them directly — no *Tag suffix.

// ❌
class UserRepoTag extends Context.Tag('UserRepo')<UserRepoTag, Service>() {}
const UserRepo = Context.GenericTag<Service>('UserRepo');
class UserRepo extends Effect.Service<Service>()('UserRepo', { ... }) {}

// ✅
class UserRepo extends Context.Service<UserRepo, Service>()('UserRepo') {}

effect-catchall-default

Blanket Effect.catch / Effect.catchCause returning a default value silently swallows every failure mode — including ones you didn't know about. Use catchTag / catchTags for targeted recovery.

// ❌
effect.pipe(Effect.catch(() => Effect.succeed(defaultUser)));

// ✅
effect.pipe(
    Effect.catchTags({
        UserNotFound: () => Effect.succeed(defaultUser),
        NetworkError: (e) => Effect.fail(e) // re-raise the rest
    })
);

effect-promise-vs-trypromise

Effect.promise treats rejections as defects outside the typed error channel. Defect/cause handlers can still observe them. Use Effect.tryPromise for expected rejections so callers can handle typed failures with catchTag.

// ❌
const user = yield* Effect.promise(() => fetchUser(id));

// ✅
const user =
    yield* Effect.tryPromise({
        try: () => fetchUser(id),
        catch: (cause) => new FetchFailed({ cause })
    });

effect-run-in-body

Effect.runSync / runPromise / runFork collapse the program down to a concrete value. Keep them at the boundary (main.ts, the test harness, the HTTP route handler) and return Effect values everywhere else.

// ❌  inside a service method
const get = (id: string) => {
    const user = Effect.runSync(fetchUser(id));
    return user;
};

// ✅
const get = (id: string) => fetchUser(id);

imperative-loops

for, while, and do…while over collections obscure the intent of the transformation. Use Arr.map, Arr.filter, Arr.filterMap, Arr.reduce, or Effect.forEach so the operation is on the page.

// ❌
const names: Array<string> = [];
for (const user of users) {
    if (user.active) names.push(user.name);
}
import * as Arr from 'effect/Array';
import * as Result from 'effect/Result';

declare const users: ReadonlyArray<{ readonly active: boolean; readonly name: string }>;
const names = Arr.filterMap(users, (u) =>
    u.active ? Result.succeed(u.name) : Result.fail(undefined)
);

no-barrel-imports

This stricter import policy makes each dependency's owning module explicit. Import namespaces from submodules; root function exports such as pipe and flow belong to effect/Function. This is a style policy, not a claim that named imports inherently disable tree-shaking.

// ❌
import { Effect, Array as Arr, Option } from 'effect';

// ✅
import * as Effect from 'effect/Effect';
import * as Arr from 'effect/Array';
import * as Option from 'effect/Option';

no-opaque-instance-fields

Schema.Opaque classes are pure type-level wrappers — they have no runtime identity beyond the underlying schema. Adding instance methods or fields turns them into something the type system can no longer treat as opaque.

// ❌
class UserId extends Schema.Opaque<UserId>()(Schema.String) {
    greet() {
        return `Hello ${this.toString()}`;
    }
}

// ✅
class UserId extends Schema.Opaque<UserId>()(Schema.String) {}
const greet = (id: UserId) => `Hello ${id}`;

prefer-arr-match

Manual .length === 0 / .length > 0 branching obscures the empty vs non-empty intent. Arr.match makes both branches explicit and gives you the non-empty array witness in the body.

// ❌
if (items.length === 0) return placeholder;
return list(items);

// ✅
return Arr.match(items, {
    onEmpty: () => placeholder,
    onNonEmpty: (xs) => list(xs)
});

prefer-arr-sort

Array.prototype.sort mutates in place, sorts lexicographically by default, and has no notion of an Order. Arr.sort is immutable and composes with Order combinators.

// ❌
const sorted = [...users].sort((a, b) => a.age - b.age);

// ✅
const sorted = Arr.sort(
    users,
    Order.mapInput(Order.Number, (u: User) => u.age)
);

prefer-duration-constructors

Raw millisecond literals passed to Effect.sleep, Schedule.spaced, etc. read poorly. Duration.seconds, Duration.minutes, Duration.millis keep units at the call site.

// ❌
yield* Effect.sleep(5000);
const sched = Schedule.spaced(60_000);

// ✅
yield* Effect.sleep(Duration.seconds(5));
const sched = Schedule.spaced(Duration.minutes(1));

prefer-effect-fn

Effect.gen(function*() { ... }) assigned to a const, or used as a service method, lacks an attached span name. Effect.fn("name")(function*() { ... }) adds automatic tracing.

// ❌
const getUser = (id: string) => Effect.gen(function* () { ... });

const make = Effect.gen(function* () {
    return UserRepo.of({
        get: (id) => Effect.gen(function* () { ... })
    });
});

// ✅
const getUser = Effect.fn('getUser')(function* (id: string) { ... });

const make = Effect.gen(function* () {
    return UserRepo.of({
        get: Effect.fn('UserRepo.get')(function* (id) { ... })
    });
});

prefer-effect-is

typeof x === "string" is non-composable and doesn't narrow union types as cleanly as Effect's Predicate helpers.

// ❌
if (typeof value === 'string') return value;
if (typeof n === 'number' && n > 0) return n;

// ✅
import * as P from 'effect/Predicate';
if (P.isString(value)) return value;
if (P.isNumber(n) && n > 0) return n;

prefer-match-over-switch

switch is not exhaustive (TypeScript can't prove every case is handled) and doesn't compose with pipe. Match.value is exhaustive, expression-level, and pipe-friendly.

// ❌
switch (status) {
    case 'Pending':
        return spinner();
    case 'Active':
        return view();
    case 'Closed':
        return summary();
}

// ✅
return Match.value(status).pipe(
    Match.when('Pending', () => spinner()),
    Match.when('Active', () => view()),
    Match.when('Closed', () => summary()),
    Match.exhaustive
);

prefer-namespace-imports

Use namespace imports for helper modules with the canonical alias (Arr for effect/Array, Option, R for effect/Record, etc.). Named area imports such as import { HttpClient } from 'effect/http' remain valid.

// ❌
import { map, filter } from 'effect/Array';
import { Array } from 'effect';

// ✅
import * as Arr from 'effect/Array';
import * as Effect from 'effect/Effect';

prefer-option-over-null

T | null / T | undefined doesn't compose: every caller has to repeat the null check. Option<T> ships map, flatMap, match, getOrElse, and friends.

// ❌
function find(id: string): User | null { ... }

// ✅
function find(id: string): Option.Option<User> { ... }

prefer-redacted-config

Configuration keys whose name conventionally identifies a secret (apiKey, authToken, password, privateKey, dsn, etc.) should use Config.Redacted(...) or Schema.Redacted inside Config.schema. For Config.NonEmptyString, retain the constraint with Config.schema(Schema.Redacted(Schema.NonEmptyString), key). Legacy lowercase loaders are also detected. Redaction masks logging and inspection; it does not prevent intentional unwrapping or all encoding.

// ❌
const apiKey = yield* Config.String('apiKey');
const cfg =
    yield* Config.schema(
        Schema.Struct({
            apiKey: Schema.String
        })
    );
import * as Config from 'effect/Config';
import * as Schema from 'effect/Schema';

const apiKey = Config.Redacted('apiKey');
const nonEmptyToken = Config.schema(Schema.Redacted(Schema.NonEmptyString), 'TOKEN');
const cfg = Config.schema(
    Schema.Struct({
        apiKey: Schema.Redacted(Schema.String)
    })
);

require-effect-concurrency

Effect.all, Effect.forEach, Effect.validate, and friends silently default to sequential execution. Sequential is sometimes correct — but it's a concurrency decision, so it should be reviewable at the call site.

// ❌
yield* Effect.forEach(ids, fetchUser);

// ✅
yield* Effect.forEach(ids, fetchUser, { concurrency: 'unbounded' });
yield* Effect.forEach(ids, fetchUser, { concurrency: 4 });
yield* Effect.forEach(ids, fetchUser, { concurrency: 1 }); // explicit sequential

require-filter-metadata

Schema.makeFilter and Schema.makeFilterGroup produce reusable validators. Without identifier, title, and description they show up in error messages and OpenAPI docs as opaque blobs.

// ❌
const PositiveInt = Schema.makeFilter((n: number) => n > 0);

// ✅
const PositiveInt = Schema.makeFilter((n: number) => n > 0, {
    identifier: 'PositiveInt',
    title: 'Positive integer',
    description: 'A whole number strictly greater than zero.'
});

require-schema-type-alias

Exported Schema.Struct / Schema.TaggedStruct / Schema.Literals constants don't carry a TypeScript type at the value name. Pair them with export type Foo = typeof Foo.Type so importers can refer to the inferred type.

Guards, checks (Schema.isIncluding, Schema.isBetweenLength, etc.), filters, equivalences, and formatters are not schema values and do not need this alias.

// ❌
export const User = Schema.Struct({ id: Schema.String });

// ✅
export const User = Schema.Struct({ id: Schema.String });
export type User = typeof User.Type;

throw-in-effect-gen

throw inside Effect.gen / Effect.fn / Effect.fnUntraced becomes a defect outside the typed error channel. Use yield* Effect.fail(new MyError(...)) (or yield* new MyTaggedError({ ... })) for expected failures. Direct thunks and the try: arm of Effect.tryPromise / Effect.try are excluded because those boundaries capture throws.

// ❌
Effect.gen(function* () {
    if (!user) throw new Error('User missing');
    return user;
});

// ✅
Effect.gen(function* () {
    if (!user) return yield* Effect.fail(new UserMissing({ id }));
    return user;
});

use-clock-service

new Date() / Date.now() / Date.UTC() are non-deterministic and untestable. Use the Clock service or the DateTime module so tests can freeze time.

// ❌
const now = new Date();
const ms = Date.now();

// ✅
const now = yield* DateTime.now;
const ms = yield* Clock.currentTimeMillis;

use-command-executor-service

child_process / node:child_process ties code to Node's process model and yields untyped errors. Use ChildProcessSpawner + ChildProcess from effect/process for typed, scoped, composable process spawning. Provide the platform spawner layer at the runtime boundary.

// ❌
import { spawn } from 'node:child_process';
const proc = spawn('git', ['status']);
import * as Effect from 'effect/Effect';
import { ChildProcess, ChildProcessSpawner } from 'effect/process';

const status = Effect.gen(function* () {
    const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
    return yield* spawner.string(ChildProcess.make('git', ['status']));
});

use-console-service

console.* writes to stdout / stderr without spans, structured fields, or test capture. Use Effect.logInfo / logError / logWarning / logDebug (preferred), or the Console service.

// ❌
console.log('Fetched user', user.id);
console.error('Failed', err);

// ✅
yield* Effect.logInfo('Fetched user').pipe(
    Effect.annotateLogs('userId', user.id)
);
yield* Effect.logError('Failed').pipe(Effect.annotateLogs('cause', err));

use-filesystem-service

fs, node:fs, and fs/promises tie code to Node's runtime. The FileSystem service from effect/FileSystem is portable, layer-substitutable, and integrates with Stream, Scope, and the rest of Effect.

// ❌
import * as fs from 'node:fs/promises';
const text = await fs.readFile(path, 'utf8');

// ✅
import * as FileSystem from 'effect/FileSystem';
const fs = yield* FileSystem.FileSystem;
const text = yield* fs.readFileString(path);

use-http-client-service

Direct http / https imports expose low-level transport handling. Use HttpClient, HttpClientRequest, and HttpClientResponse from effect/http for typed responses and testable layer substitution. Add retries explicitly, and only for proven-idempotent operations.

// ❌
import * as https from 'node:https';
https.get(url, (res) => { ... });

// ✅
const client = yield* HttpClient.HttpClient;
const json = yield* client.get(url).pipe(
    Effect.flatMap(HttpClientResponse.schemaBodyJson(Payload))
);

use-path-service

node:path is Posix-or-Windows-flavored depending on the runtime. The Path service from effect/Path is explicit about which variant you're using and is testable / mockable.

// ❌
import * as path from 'node:path';
const full = path.join(dir, name);

// ✅
import * as Path from 'effect/Path';
const path_ = yield* Path.Path;
const full = path_.join(dir, name);

use-random-service

Math.random() bypasses Effect's random service. Use Random.withSeed for repeatable sequences. Random.nextIntBetween(min, max) includes both endpoints by default; use { halfOpen: true } to exclude the upper bound.

// ❌
const n = Math.floor(Math.random() * 100);
import * as Random from 'effect/Random';

const n = Random.nextIntBetween(0, 100, { halfOpen: true }).pipe(
    Random.withSeed('example')
);

use-temp-file-scoped

os.tmpdir() and unscoped FileSystem.makeTempFile / makeTempDirectory leak temp files when the program crashes. Use FileSystem.makeTempFileScoped / makeTempDirectoryScoped so cleanup is tied to the Scope.

// ❌
import { tmpdir } from 'node:os';
const dir = path.join(tmpdir(), 'work');

// ✅
const fs = yield* FileSystem.FileSystem;
const dir = yield* fs.makeTempDirectoryScoped();

vm-in-wrong-file

View Model interfaces and their layers belong in .vm.ts files. Co-locating them with the component flattens the seam between rendering and state management — and the seam is the whole point of the VM pattern.

// ❌  profile.tsx
export interface ProfileVM { ... }
export const ProfileVMLive = Layer.effect(...);

// ✅  profile.vm.ts
export interface ProfileVM { ... }
export const ProfileVMLive = Layer.effect(...);

yield-in-for-loop

yield* inside a for loop forces sequential execution and hides the iteration intent. Effect.forEach is declarative and parallelizable.

// ❌
for (const id of ids) {
    yield* fetchUser(id);
}

// ✅
yield* Effect.forEach(ids, fetchUser, { concurrency: 'unbounded' });

maybe-prefix-requires-option

A field named maybeX promises an Option<X>. Using the prefix with a plain T | null, T | undefined, or a Schema optional/nullable field breaks reader expectations.

maybe* names should be typed as Option<T> in TypeScript and as Schema.Option(...) / Schema.OptionFromNullishOr(...) in Schema structs. If the value really is nullable rather than optional, rename it to nullableX.

no-effect-ignore-then-as

Effect.ignore discards both the success value and the error channel. When it appears immediately before Effect.as(...), the as already discards the success value, so ignore only erases failures silently.

The rule also flags Effect.ignore on known infallible primitives where there is no error channel to ignore.

no-length-comparison

Manual .length === 0, .length > 0, and related checks hide whether the value is a string or an array. Prefer named predicates such as Str.isEmpty, Str.isNonEmpty, Arr.isReadonlyArrayEmpty, Arr.isReadonlyArrayNonEmpty, or branch with Arr.match.

prefer-array-fromoption-over-option-match-empty

Option<A> to ReadonlyArray<A> is Array.fromOption. Spelling that as Option.match({ onNone: () => [], onSome: (v) => [v] }) obscures the intent.

require-is-prefix-for-boolean-schema-field

Boolean Schema fields should read as predicates at call sites. Use prefixes such as is*, has*, can*, should*, was*, or will* for Schema.Boolean fields.

no-cause-in-http-response

Cause.pretty/Cause.squash render full failure detail — defect stack traces, decode internals — as text. Rendering that (or a caught cause/error binding, directly, via .message, String(...), or template interpolation) into an HTTP response body turns an internal failure into an information disclosure. Log the cause server-side and return a generic body instead.

// ❌
const failureResponse = (cause: Cause.Cause<unknown>) =>
    HttpServerResponse.text(Cause.pretty(cause), { status: 500 });

// ✅
const failureResponse = (cause: Cause.Cause<unknown>) =>
    Effect.logError(`Request failed: ${Cause.pretty(cause)}`).pipe(
        Effect.as(HttpServerResponse.text('Internal error.', { status: 500 }))
    );

Suppression

All rules respect oxlint's standard disable directives:

// oxlint-disable-next-line effect/<rule-name> -- reason

/* oxlint-disable effect/<rule-name> -- reason */
// ... block ...
/* oxlint-enable effect/<rule-name> */

A trailing -- <reason> comment is encouraged for any suppression that lives longer than a single PR review.

Development

From the repository root:

bun install
bun run --filter @enfect/oxlint test   # Vitest on Node; oxlint's RuleTester refuses Bun
bun run check                          # format, lint, and typecheck

Each rule lives in src/rules/<rule-name>.ts with a sibling test in test/rules/<rule-name>.test.ts. The rule SDK is documented at effect-oxlint.

Many rules have an ast-grep counterpart in @enfect/patterns, which the agent plugins run after each write.

License

MIT. Anti-slop-derived rules and fixtures retain their upstream MIT notice in licenses/anti-slop.txt; see licenses/README.md for provenance.

版本与发布记录

@enfect/oxlint 共发布 4 个版本,首次发布于 2026/10/29;过去 12 个月发布了 4 个版本。

版本发布日期距今
0.10.0latest2026/11/0211 天前
0.9.02026/10/3014 天前
0.8.02026/10/2915 天前
0.0.0-stage2026/10/2915 天前

关于 @enfect/oxlint 的常见问题

@enfect/oxlint 是什么?

Oxlint plugin enforcing Effect-first development conventions

如何安装 @enfect/oxlint?

在项目目录运行 npm install @enfect/oxlint,或使用 pnpm add / yarn add / bun add。

@enfect/oxlint 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@enfect/oxlint 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@enfect/oxlint 可以商用吗?

@enfect/oxlint 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@enfect/oxlint 的下载量有多少?

根据 npm 官方下载统计,@enfect/oxlint 在 2026/10/14 – 2026/11/12 这 30 天内被下载 5,950 次,日均约 198 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@enfect/oxlint 还在维护吗?

最新版本 0.10.0 发布于 2026/11/02(11 天前);至今共发布 4 个版本,其中过去 12 个月发布了 4 个;首次发布于 2026/10/29。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。