{"id":51538585,"url":"https://github.com/mpsuesser/oxlint-plugin-effect","last_synced_at":"2026-07-09T11:01:39.639Z","repository":{"id":357298872,"uuid":"1198168748","full_name":"mpsuesser/oxlint-plugin-effect","owner":"mpsuesser","description":"Opinionated oxlint plugin for Effect v4 — flags imperative patterns, raw Node APIs, untyped errors, and other non-idiomatic shapes.","archived":false,"fork":false,"pushed_at":"2026-05-23T11:16:07.000Z","size":312,"stargazers_count":10,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-23T12:27:16.578Z","etag":null,"topics":["effect","oxlint","patterns","rules","v4"],"latest_commit_sha":null,"homepage":"https://www.npmjs.com/package/@mpsuesser/oxlint-plugin-effect","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mpsuesser.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-01T07:14:20.000Z","updated_at":"2026-05-23T11:15:43.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mpsuesser/oxlint-plugin-effect","commit_stats":null,"previous_names":["mpsuesser/oxlint-plugin-effect"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/mpsuesser/oxlint-plugin-effect","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpsuesser%2Foxlint-plugin-effect","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpsuesser%2Foxlint-plugin-effect/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpsuesser%2Foxlint-plugin-effect/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpsuesser%2Foxlint-plugin-effect/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mpsuesser","download_url":"https://codeload.github.com/mpsuesser/oxlint-plugin-effect/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mpsuesser%2Foxlint-plugin-effect/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35296693,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-09T02:00:07.329Z","response_time":57,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["effect","oxlint","patterns","rules","v4"],"created_at":"2026-07-09T11:01:38.720Z","updated_at":"2026-07-09T11:01:39.630Z","avatar_url":"https://github.com/mpsuesser.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# oxlint-plugin-effect\n\n[![npm](https://img.shields.io/npm/v/@mpsuesser/oxlint-plugin-effect)](https://www.npmjs.com/package/@mpsuesser/oxlint-plugin-effect)\n[![JSR](https://jsr.io/badges/@mpsuesser/oxlint-plugin-effect)](https://jsr.io/@mpsuesser/oxlint-plugin-effect)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)\n\nAn opinionated [oxlint](https://oxc.rs/docs/guide/usage/linter) plugin for [Effect v4](https://effect.website) 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.\n\nThe plugin ships **59 rules** namespaced under `effect/`. Rules are implemented with the [`effect-oxlint`](https://github.com/mpsuesser/effect-oxlint) SDK and run as standard oxlint custom rules.\n\n## Installation\n\n```sh\nnpm install @mpsuesser/oxlint-plugin-effect\n# or\nbun add @mpsuesser/oxlint-plugin-effect\n```\n\nUse the generated recommended config from `oxlint.config.ts`:\n\n```ts\nimport { defineConfig } from 'oxlint';\nimport effect from '@mpsuesser/oxlint-plugin-effect';\n\nexport default defineConfig({\n\textends: [effect.configs.recommended]\n});\n```\n\n`configs.recommended` registers the package through oxlint's `jsPlugins` field and enables all 59 rules at `error` severity.\n\nTo override an individual rule, add a `rules` entry after the `extends` block:\n\n```ts\nimport { defineConfig } from 'oxlint';\nimport effect from '@mpsuesser/oxlint-plugin-effect';\n\nexport default defineConfig({\n\textends: [effect.configs.recommended],\n\trules: {\n\t\t'effect/avoid-native-object-helpers': 'off',\n\t\t'effect/avoid-direct-json': 'warn'\n\t}\n});\n```\n\nIf you use `.oxlintrc.json`, oxlint cannot import a package config object. Configure the JS plugin and any rules you want explicitly:\n\n```jsonc\n{\n\t\"jsPlugins\": [\"@mpsuesser/oxlint-plugin-effect\"],\n\t\"rules\": {\n\t\t\"effect/avoid-direct-json\": \"error\"\n\t}\n}\n```\n\nUse `oxlint.config.ts` when you want the full generated recommended config.\n\n## Rules at a glance\n\n| Rule                                                            | What it catches                                                                  |\n| --------------------------------------------------------------- | -------------------------------------------------------------------------------- |\n| [`avoid-any`](#avoid-any)                                       | `as any` and `as unknown as T` casts                                             |\n| [`avoid-data-tagged-error`](#avoid-data-tagged-error)           | `Data.TaggedError` — use `Schema.TaggedErrorClass`                               |\n| [`avoid-direct-json`](#avoid-direct-json)                       | `JSON.parse` / `JSON.stringify` — use `Schema.fromJsonString`                    |\n| [`avoid-direct-tag-checks`](#avoid-direct-tag-checks)           | `x._tag === \"...\"` checks — use `$is` / `$match` / `Match`                       |\n| [`avoid-expect-in-if`](#avoid-expect-in-if)                     | `expect(...)` nested inside `if` blocks in tests                                 |\n| [`avoid-mutable-state`](#avoid-mutable-state)                   | `let` bindings inside service / layer factories                                  |\n| [`avoid-native-fetch`](#avoid-native-fetch)                     | Native `fetch()` — use Effect `HttpClient`                                       |\n| [`avoid-native-object-helpers`](#avoid-native-object-helpers)   | `Object.keys` / `Object.entries` / `Object.fromEntries` etc.                     |\n| [`avoid-node-imports`](#avoid-node-imports)                     | Bare `node:*` imports in platform-agnostic code                                  |\n| [`avoid-non-null-assertion`](#avoid-non-null-assertion)         | The `!` non-null assertion operator                                              |\n| [`avoid-object-type`](#avoid-object-type)                       | `Object` and `{}` as types                                                       |\n| [`avoid-option-getorthrow`](#avoid-option-getorthrow)           | `.getOrThrow` on `Option` / `Either` / `Result`                                  |\n| [`avoid-platform-coupling`](#avoid-platform-coupling)           | `@effect/platform-bun` imports in binding packages                               |\n| [`avoid-process-env`](#avoid-process-env)                       | `process.env` — use a `Config` service                                           |\n| [`avoid-react-hooks`](#avoid-react-hooks)                       | `useState` / `useEffect` / `useReducer` — use Effect Atom VMs                    |\n| [`avoid-schema-suffix`](#avoid-schema-suffix)                   | Schema constants suffixed with `Schema`                                          |\n| [`avoid-sync-fs`](#avoid-sync-fs)                               | `fs.readFileSync` and other synchronous `fs` calls                               |\n| [`avoid-try-catch`](#avoid-try-catch)                           | `try` / `catch` in Effect code                                                   |\n| [`avoid-ts-ignore`](#avoid-ts-ignore)                           | `@ts-ignore` / `@ts-expect-error` comments                                       |\n| [`avoid-untagged-errors`](#avoid-untagged-errors)               | `new Error(...)` and `instanceof Error` for recoverable failures                 |\n| [`avoid-yield-ref`](#avoid-yield-ref)                           | `yield* ref` / `yield* deferred` / `yield* fiber` (removed in v4)                |\n| [`casting-awareness`](#casting-awareness)                       | `as T` assertions (excluding `as const` / `as never`)                            |\n| [`context-tag-extends`](#context-tag-extends)                   | `Context.Tag` / `Context.GenericTag` / `Effect.Service` / legacy `ServiceMap.*`  |\n| [`effect-catchall-default`](#effect-catchall-default)           | Blanket `Effect.catch` / `catchCause` swallowing all errors                      |\n| [`effect-promise-vs-trypromise`](#effect-promise-vs-trypromise) | `Effect.promise` — prefer `Effect.tryPromise`                                    |\n| [`effect-run-in-body`](#effect-run-in-body)                     | `Effect.runSync` / `runPromise` / `runFork` outside entrypoints                  |\n| [`imperative-loops`](#imperative-loops)                         | `for` / `while` / `do…while` in domain code                                      |\n| [`maybe-prefix-requires-option`](#maybe-prefix-requires-option) | `maybe*` named field that is not an `Option\u003cT\u003e`                              |\n| [`no-barrel-imports`](#no-barrel-imports)                       | Named imports from the `effect` barrel package                                   |\n| [`no-effect-ignore-then-as`](#no-effect-ignore-then-as)       | Redundant `Effect.ignore` before `Effect.as`, or on known infallible primitives |\n| [`no-length-comparison`](#no-length-comparison)               | `.length === 0` and friends — use named string/array predicates                |\n| [`no-opaque-instance-fields`](#no-opaque-instance-fields)       | Instance members on `Schema.Opaque` classes                                      |\n| [`prefer-arr-match`](#prefer-arr-match)                         | Manual empty / non-empty branching — use `Arr.match`                             |\n| [`prefer-arr-sort`](#prefer-arr-sort)                           | `Array.prototype.sort` — use `Arr.sort` with an `Order`                          |\n| [`prefer-array-fromoption-over-option-match-empty`](#prefer-array-fromoption-over-option-match-empty) | `Option.match` that produces `[]` / `[v]`                                  |\n| [`prefer-duration-constructors`](#prefer-duration-constructors) | Raw millisecond numbers passed to Effect timing APIs                             |\n| [`prefer-effect-fn`](#prefer-effect-fn)                         | `Effect.gen` bound to a const or used in a service method                        |\n| [`prefer-effect-is`](#prefer-effect-is)                         | `typeof x === \"string\"` — use `P.isString` and friends                           |\n| [`prefer-match-over-switch`](#prefer-match-over-switch)         | `switch` statements — use `Match.value`                                          |\n| [`prefer-namespace-imports`](#prefer-namespace-imports)         | Named imports from Effect submodules — use namespace imports                     |\n| [`prefer-option-over-null`](#prefer-option-over-null)           | `T \\| null` / `T \\| undefined` union types                                       |\n| [`prefer-redacted-config`](#prefer-redacted-config)             | `Config.string(\"apiKey\")` etc. for secret-looking keys                           |\n| [`prefer-schema-class`](#prefer-schema-class)                   | `Schema.Struct` for named types — prefer `Schema.Class`                          |\n| [`require-effect-concurrency`](#require-effect-concurrency)     | `Effect.all` / `forEach` / `validateAll` without explicit `concurrency`          |\n| [`require-filter-metadata`](#require-filter-metadata)           | `Schema.makeFilter` / `makeFilterGroup` missing identifier / title / description |\n| [`require-is-prefix-for-boolean-schema-field`](#require-is-prefix-for-boolean-schema-field) | `Schema.Boolean` fields without a boolean predicate prefix             |\n| [`require-schema-type-alias`](#require-schema-type-alias)       | Exported schema constant without a matching `export type` alias                  |\n| [`stream-large-files`](#stream-large-files)                     | `fs.readFile` on paths that look like large / unbounded files                    |\n| [`throw-in-effect-gen`](#throw-in-effect-gen)                   | `throw` inside `Effect.gen` / `Effect.fn` / `Effect.fnUntraced`                  |\n| [`use-clock-service`](#use-clock-service)                       | `new Date()` / `Date.now()` / `Date.UTC()` — use `Clock` / `DateTime`            |\n| [`use-command-executor-service`](#use-command-executor-service) | `child_process` / `node:child_process` imports                                   |\n| [`use-console-service`](#use-console-service)                   | `console.*` — use `Effect.log*` / `Console`                                      |\n| [`use-filesystem-service`](#use-filesystem-service)             | `fs` / `node:fs` / `fs/promises` imports                                         |\n| [`use-http-client-service`](#use-http-client-service)           | `http` / `https` / `node:http` / `node:https` imports                            |\n| [`use-path-service`](#use-path-service)                         | `path` / `node:path` imports                                                     |\n| [`use-random-service`](#use-random-service)                     | `Math.random()` — use the `Random` service                                       |\n| [`use-temp-file-scoped`](#use-temp-file-scoped)                 | `os.tmpdir()` / unscoped `makeTempFile` / `makeTempDirectory`                    |\n| [`vm-in-wrong-file`](#vm-in-wrong-file)                         | View Model interfaces and layers outside `.vm.ts` files                          |\n| [`yield-in-for-loop`](#yield-in-for-loop)                       | `yield*` inside `for` loops — use `Effect.forEach`                               |\n\n---\n\n### `avoid-any`\n\n`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.\n\n```ts\n// ❌\nconst user = data as any;\nconst config = JSON.parse(raw) as unknown as Config;\n\n// ✅\nconst user = yield * Schema.decodeUnknown(User)(data);\nconst config = yield * Schema.decodeUnknownString(Config)(raw);\n```\n\n### `avoid-data-tagged-error`\n\n`Data.TaggedError` does not integrate with the Schema encode / decode pipeline. Use `Schema.TaggedErrorClass` so errors round-trip through RPC, serialization, and structured logging.\n\n```ts\n// ❌\nclass NotFound extends Data.TaggedError('NotFound')\u003c{ id: string }\u003e {}\n\n// ✅\nclass NotFound extends Schema.TaggedErrorClass\u003cNotFound\u003e('NotFound')(\n\t'NotFound',\n\t{ id: Schema.String }\n) {}\n```\n\n### `avoid-direct-json`\n\n`JSON.parse` produces `any` and `JSON.stringify` swallows schema shape. Use `Schema.fromJsonString(MySchema)` at typed boundaries or `Schema.UnknownFromJsonString` for unknown payloads.\n\n```ts\n// ❌\nconst user: User = JSON.parse(raw);\nconst body = JSON.stringify(payload);\n\n// ✅\nconst user = yield * Schema.decode(Schema.fromJsonString(User))(raw);\nconst body = yield * Schema.encode(Schema.fromJsonString(Payload))(payload);\n```\n\n### `avoid-direct-tag-checks`\n\nReading `_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.\n\n```ts\n// ❌\nif (result._tag === 'Success') return result.value;\nswitch (msg._tag) {\n\tcase 'Loaded':\n\t\t/* ... */\n}\n\n// ✅\nif (Result.$is('Success')(result)) return result.value;\nreturn Match.value(msg).pipe(\n\tMatch.tag('Loaded', (m) =\u003e /* ... */),\n\tMatch.exhaustive\n);\n```\n\n### `avoid-expect-in-if`\n\n`expect(...)` nested inside an `if` block silently passes when the condition is false. Narrow first, then assert.\n\n```ts\n// ❌\nif (result) {\n\texpect(result.id).toBe('abc');\n}\n\n// ✅\nexpect(result).toBeDefined();\nexpect(result.id).toBe('abc');\n```\n\n### `avoid-mutable-state`\n\n`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.\n\n```ts\n// ❌\nexport const CounterLive = Layer.effect(\n\tCounter,\n\tEffect.gen(function* () {\n\t\tlet count = 0;\n\t\treturn Counter.of({ inc: () =\u003e Effect.sync(() =\u003e count++) });\n\t})\n);\n\n// ✅\nexport const CounterLive = Layer.effect(\n\tCounter,\n\tEffect.gen(function* () {\n\t\tconst count = yield* Ref.make(0);\n\t\treturn Counter.of({ inc: () =\u003e Ref.update(count, (n) =\u003e n + 1) });\n\t})\n);\n```\n\n### `avoid-native-fetch`\n\nNative `fetch()` returns a `Promise\u003cResponse\u003e` with untyped errors. Effect's `HttpClient` gives you typed errors, request / response schemas, and testable layer substitution.\n\n```ts\n// ❌\nconst res = await fetch('/api/users');\nconst users = await res.json();\n\n// ✅\nconst client = yield * HttpClient.HttpClient;\nconst users =\n\tyield *\n\tclient\n\t\t.get('/api/users')\n\t\t.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(UserList)));\n```\n\n### `avoid-native-object-helpers`\n\n`Object.keys` returns `string[]` (not `keyof T`); `Object.entries` loses value types. Use the `effect/Record` helpers for type-safe equivalents.\n\n```ts\n// ❌\nconst keys = Object.keys(user);\nconst entries = Object.entries(config);\nconst obj = Object.fromEntries(pairs);\n\n// ✅\nimport * as R from 'effect/Record';\nconst keys = R.keys(user);\nconst entries = R.toEntries(config);\nconst obj = R.fromEntries(pairs);\n```\n\n### `avoid-node-imports`\n\n`node:*` imports tie domain code to a single runtime. Use `@effect/platform` abstractions so the same code runs on Node, Bun, Deno, and Workers. 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.\n\n```ts\n// ❌\nimport { createHash } from 'node:crypto';\nimport { Readable } from 'node:stream';\n\n// ✅  Provide a service or pass a `FromEffect`-shaped Layer\nimport { Crypto } from '@effect/platform/Crypto';\nimport { Stream } from 'effect/Stream';\n```\n\n### `avoid-non-null-assertion`\n\n`!` 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`.\n\n```ts\n// ❌\nconst name = user!.profile!.displayName!;\n\n// ✅\nconst name = Option.fromNullishOr(user).pipe(\n\tOption.flatMapNullishOr((u) =\u003e u.profile?.displayName),\n\tOption.getOrElse(() =\u003e 'Anonymous')\n);\n```\n\n### `avoid-object-type`\n\n`Object` provides no type safety, and `{}` matches any non-nullish value (including `42` and `\"hi\"`). Use a specific interface, `Record\u003cstring, unknown\u003e`, or a `Schema.Class`.\n\n```ts\n// ❌\nfunction merge(a: object, b: {}): object { ... }\n\n// ✅\nfunction merge\u003cA extends Record\u003cstring, unknown\u003e\u003e(a: A, b: Partial\u003cA\u003e): A { ... }\n```\n\n### `avoid-option-getorthrow`\n\n`.getOrThrow` defeats the point of `Option` / `Either` / `Result` by throwing where the type promised a total handler. Use `match`, `getOrElse`, or `map`.\n\n```ts\n// ❌\nconst value = Option.getOrThrow(maybeUser);\n\n// ✅\nconst value = Option.match(maybeUser, {\n\tonNone: () =\u003e defaultUser,\n\tonSome: (u) =\u003e u\n});\n```\n\n### `avoid-platform-coupling`\n\nPackages under `packages/*/binding/` are the seam where platform-specific code lives — they import `@effect/platform-bun`, `@effect/platform-node`, etc. Code outside the `binding/` directory must stay platform-agnostic so it can run anywhere.\n\n```ts\n// ❌  packages/myapp/src/MyService.ts\nimport { BunHttpServer } from '@effect/platform-bun';\n\n// ✅  packages/myapp/binding/index.ts\nimport { BunHttpServer } from '@effect/platform-bun';\n```\n\n### `avoid-process-env`\n\n`process.env` is untyped, untested, and global. `Config.*` builds typed, layered, redactable configuration with default values and validation.\n\n```ts\n// ❌\nconst apiKey = process.env.API_KEY!;\nconst port = parseInt(process.env.PORT ?? '3000');\n\n// ✅\nconst apiKey = yield * Config.redacted('API_KEY');\nconst port = yield * Config.integer('PORT').pipe(Config.withDefault(3000));\n```\n\n### `avoid-react-hooks`\n\nReact 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.\n\n```ts\n// ❌\nfunction Profile({ id }: Props) {\n\tconst [user, setUser] = useState\u003cUser\u003e();\n\tuseEffect(() =\u003e { fetchUser(id).then(setUser); }, [id]);\n\treturn \u003cdiv\u003e{user?.name}\u003c/div\u003e;\n}\n\n// ✅\n// profile.vm.ts\nexport const userAtom = Atom.family((id: string) =\u003e\n\tAtom.fn(Effect.fn('fetchUser')(function* () { ... }))\n);\n\n// profile.tsx\nfunction Profile({ id }: Props) {\n\tconst user = useAtomValue(userAtom(id));\n\treturn \u003cdiv\u003e{user.name}\u003c/div\u003e;\n}\n```\n\n### `avoid-schema-suffix`\n\nSchema 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.\n\n```ts\n// ❌\nconst UserSchema = Schema.Struct({ id: Schema.String });\n\n// ✅\nconst User = Schema.Struct({ id: Schema.String });\nexport type User = typeof User.Type;\n```\n\n### `avoid-sync-fs`\n\nSynchronous `fs` calls block the event loop. Use `FileSystem` from `@effect/platform` for async, composable, testable file I/O.\n\n```ts\n// ❌\nconst text = fs.readFileSync(path, 'utf8');\nfs.writeFileSync(path, data);\n\n// ✅\nconst fs = yield * FileSystem.FileSystem;\nconst text = yield * fs.readFileString(path);\nyield * fs.writeFileString(path, data);\n```\n\n### `avoid-try-catch`\n\n`try` / `catch` discards the failure type and forces every caller to inspect a generic `unknown`. Use `Effect.try` or `Effect.tryPromise` with `Schema.TaggedErrorClass` to keep errors in the typed channel.\n\n```ts\n// ❌\ntry {\n\treturn JSON.parse(raw);\n} catch (e) {\n\treturn null;\n}\n\n// ✅\nreturn (\n\tyield *\n\tEffect.try({\n\t\ttry: () =\u003e JSON.parse(raw),\n\t\tcatch: () =\u003e new ParseFailed({ raw })\n\t})\n);\n```\n\n### `avoid-ts-ignore`\n\n`@ts-ignore` and `@ts-expect-error` mask real bugs and silently rot when the underlying type changes. Fix the type at the source instead.\n\n```ts\n// ❌\n// @ts-ignore\nconst result = someApi.experimental.method();\n\n// ✅\n// Augment the third-party type or wrap in a typed adapter\ndeclare module 'some-api' {\n\tinterface ExperimentalApi {\n\t\tmethod(): Result;\n\t}\n}\n```\n\n### `avoid-untagged-errors`\n\n`new Error(...)` and `instanceof Error` make every failure interchangeable. `Schema.TaggedErrorClass` gives each failure mode a tag that `catchTag` / `catchTags` can discriminate at the type level.\n\n```ts\n// ❌\nthrow new Error('User not found');\nif (err instanceof Error) return null;\n\n// ✅\nclass UserNotFound extends Schema.TaggedErrorClass\u003cUserNotFound\u003e(\n\t'UserNotFound'\n)('UserNotFound', { id: Schema.String }) {}\n\nyield * Effect.fail(new UserNotFound({ id }));\nyield *\n\teffect.pipe(Effect.catchTag('UserNotFound', () =\u003e Effect.succeed(null)));\n```\n\n### `avoid-yield-ref`\n\nDirect `yield* ref` / `yield* deferred` / `yield* fiber` / `yield* latch` was removed in Effect v4. Use the explicit method calls.\n\n```ts\n// ❌\nconst value = yield * counter;\nconst result = yield * deferred;\n\n// ✅\nconst value = yield * Ref.get(counter);\nconst result = yield * Deferred.await(deferred);\nconst exit = yield * Fiber.join(fiber);\nyield * Latch.await(latch);\n```\n\n### `casting-awareness`\n\nEvery `as T` assertion is a checkpoint: is the cast redundant? Can generics or `Schema.decode` replace it? Does the upstream type need fixing? `as const` and `as never` are always allowed; everything else gets flagged so the reviewer notices.\n\n```ts\n// ❌\nconst items = (data as Array\u003cUser\u003e).filter((u) =\u003e u.active);\n\n// ✅\nconst items =\n\tyield *\n\tSchema.decodeUnknown(Schema.Array(User))(data).pipe(\n\t\tEffect.map(Arr.filter((u) =\u003e u.active))\n\t);\n\n// ✅  as const is fine\nconst STATUSES = ['Pending', 'Active', 'Closed'] as const;\n```\n\n### `context-tag-extends`\n\n`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.\n\n```ts\n// ❌\nclass UserRepoTag extends Context.Tag('UserRepo')\u003cUserRepoTag, Service\u003e() {}\nconst UserRepo = Context.GenericTag\u003cService\u003e('UserRepo');\nclass UserRepo extends Effect.Service\u003cService\u003e()('UserRepo', { ... }) {}\n\n// ✅\nclass UserRepo extends Context.Service\u003cUserRepo, Service\u003e()('UserRepo') {}\n```\n\n### `effect-catchall-default`\n\nBlanket `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.\n\n```ts\n// ❌\neffect.pipe(Effect.catchAll(() =\u003e Effect.succeed(defaultUser)));\n\n// ✅\neffect.pipe(\n\tEffect.catchTags({\n\t\tUserNotFound: () =\u003e Effect.succeed(defaultUser),\n\t\tNetworkError: (e) =\u003e Effect.fail(e) // re-raise the rest\n\t})\n);\n```\n\n### `effect-promise-vs-trypromise`\n\n`Effect.promise` treats rejections as defects (unrecoverable). `Effect.tryPromise` captures them in the typed error channel so callers can `catchTag` them.\n\n```ts\n// ❌\nconst user = yield * Effect.promise(() =\u003e fetchUser(id));\n\n// ✅\nconst user =\n\tyield *\n\tEffect.tryPromise({\n\t\ttry: () =\u003e fetchUser(id),\n\t\tcatch: (cause) =\u003e new FetchFailed({ cause })\n\t});\n```\n\n### `effect-run-in-body`\n\n`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.\n\n```ts\n// ❌  inside a service method\nconst get = (id: string) =\u003e {\n\tconst user = Effect.runSync(fetchUser(id));\n\treturn user;\n};\n\n// ✅\nconst get = (id: string) =\u003e fetchUser(id);\n```\n\n### `imperative-loops`\n\n`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.\n\n```ts\n// ❌\nconst names: Array\u003cstring\u003e = [];\nfor (const user of users) {\n\tif (user.active) names.push(user.name);\n}\n\n// ✅\nconst names = Arr.filterMap(users, (u) =\u003e\n\tu.active ? Option.some(u.name) : Option.none()\n);\n```\n\n### `no-barrel-imports`\n\nNamed imports from the `effect` barrel pull the entire module graph and break tree-shaking. Import from the submodule instead.\n\n```ts\n// ❌\nimport { Effect, Array as Arr, Option } from 'effect';\n\n// ✅\nimport * as Effect from 'effect/Effect';\nimport * as Arr from 'effect/Array';\nimport * as Option from 'effect/Option';\n```\n\n### `no-opaque-instance-fields`\n\n`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.\n\n```ts\n// ❌\nclass UserId extends Schema.Opaque\u003cUserId\u003e()(Schema.String) {\n\tgreet() {\n\t\treturn `Hello ${this.toString()}`;\n\t}\n}\n\n// ✅\nclass UserId extends Schema.Opaque\u003cUserId\u003e()(Schema.String) {}\nconst greet = (id: UserId) =\u003e `Hello ${id}`;\n```\n\n### `prefer-arr-match`\n\nManual `.length === 0` / `.length \u003e 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.\n\n```ts\n// ❌\nif (items.length === 0) return placeholder;\nreturn list(items);\n\n// ✅\nreturn Arr.match(items, {\n\tonEmpty: () =\u003e placeholder,\n\tonNonEmpty: (xs) =\u003e list(xs)\n});\n```\n\n### `prefer-arr-sort`\n\n`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.\n\n```ts\n// ❌\nconst sorted = [...users].sort((a, b) =\u003e a.age - b.age);\n\n// ✅\nconst sorted = Arr.sort(\n\tusers,\n\tOrder.mapInput(Order.number, (u: User) =\u003e u.age)\n);\n```\n\n### `prefer-duration-constructors`\n\nRaw millisecond literals passed to `Effect.sleep`, `Schedule.spaced`, etc. read poorly. `Duration.seconds`, `Duration.minutes`, `Duration.millis` keep units at the call site.\n\n```ts\n// ❌\nyield * Effect.sleep(5000);\nconst sched = Schedule.spaced(60_000);\n\n// ✅\nyield * Effect.sleep(Duration.seconds(5));\nconst sched = Schedule.spaced(Duration.minutes(1));\n```\n\n### `prefer-effect-fn`\n\n`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.\n\n```ts\n// ❌\nconst getUser = (id: string) =\u003e Effect.gen(function* () { ... });\n\nconst make = Effect.gen(function* () {\n\treturn UserRepo.of({\n\t\tget: (id) =\u003e Effect.gen(function* () { ... })\n\t});\n});\n\n// ✅\nconst getUser = Effect.fn('getUser')(function* (id: string) { ... });\n\nconst make = Effect.gen(function* () {\n\treturn UserRepo.of({\n\t\tget: Effect.fn('UserRepo.get')(function* (id) { ... })\n\t});\n});\n```\n\n### `prefer-effect-is`\n\n`typeof x === \"string\"` is non-composable and doesn't narrow union types as cleanly as Effect's `Predicate` helpers.\n\n```ts\n// ❌\nif (typeof value === 'string') return value;\nif (typeof n === 'number' \u0026\u0026 n \u003e 0) return n;\n\n// ✅\nimport * as P from 'effect/Predicate';\nif (P.isString(value)) return value;\nif (P.isNumber(n) \u0026\u0026 n \u003e 0) return n;\n```\n\n### `prefer-match-over-switch`\n\n`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.\n\n```ts\n// ❌\nswitch (status) {\n\tcase 'Pending':\n\t\treturn spinner();\n\tcase 'Active':\n\t\treturn view();\n\tcase 'Closed':\n\t\treturn summary();\n}\n\n// ✅\nreturn Match.value(status).pipe(\n\tMatch.when('Pending', () =\u003e spinner()),\n\tMatch.when('Active', () =\u003e view()),\n\tMatch.when('Closed', () =\u003e summary()),\n\tMatch.exhaustive\n);\n```\n\n### `prefer-namespace-imports`\n\nNamed imports from `effect/*` submodules break tree-shaking and diverge from Effect's canonical idiom. Use namespace imports with the canonical alias (`Effect`, `Arr` for `effect/Array`, `Option`, `R` for `effect/Record`, etc.).\n\n```ts\n// ❌\nimport { map, filter } from 'effect/Array';\nimport { Array } from 'effect';\n\n// ✅\nimport * as Arr from 'effect/Array';\nimport * as Effect from 'effect/Effect';\n```\n\n### `prefer-option-over-null`\n\n`T | null` / `T | undefined` doesn't compose: every caller has to repeat the null check. `Option\u003cT\u003e` ships `map`, `flatMap`, `match`, `getOrElse`, and friends.\n\n```ts\n// ❌\nfunction find(id: string): User | null { ... }\n\n// ✅\nfunction find(id: string): Option.Option\u003cUser\u003e { ... }\n```\n\n### `prefer-redacted-config`\n\nConfiguration keys whose name conventionally identifies a secret (`apiKey`, `authToken`, `password`, `privateKey`, `dsn`, etc.) must be loaded as `Config.redacted(...)` (or wrapped in `Schema.Redacted` inside a `Config.schema`) so the value stays masked in logs and `toString`.\n\n```ts\n// ❌\nconst apiKey = yield * Config.string('apiKey');\nconst cfg =\n\tyield *\n\tConfig.schema(\n\t\tSchema.Struct({\n\t\t\tapiKey: Schema.String\n\t\t})\n\t);\n\n// ✅\nconst apiKey = yield * Config.redacted('apiKey');\nconst cfg =\n\tyield *\n\tConfig.schema(\n\t\tSchema.Struct({\n\t\t\tapiKey: Schema.Redacted(Schema.String)\n\t\t})\n\t);\n```\n\n### `prefer-schema-class`\n\n`Schema.Struct` produces a plain object type. `Schema.Class` adds a constructor, `$is`, `$match`, and a branded nominal type, all for free.\n\n```ts\n// ❌\nconst User = Schema.Struct({ id: Schema.String, name: Schema.String });\ntype User = typeof User.Type;\n\n// ✅\nclass User extends Schema.Class\u003cUser\u003e('User')({\n\tid: Schema.String,\n\tname: Schema.String\n}) {}\n```\n\n### `require-effect-concurrency`\n\n`Effect.all`, `Effect.forEach`, `Effect.validateAll`, 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.\n\n```ts\n// ❌\nyield * Effect.forEach(ids, fetchUser);\n\n// ✅\nyield * Effect.forEach(ids, fetchUser, { concurrency: 'unbounded' });\nyield * Effect.forEach(ids, fetchUser, { concurrency: 4 });\nyield * Effect.forEach(ids, fetchUser, { concurrency: 1 }); // explicit sequential\n```\n\n### `require-filter-metadata`\n\n`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.\n\n```ts\n// ❌\nconst PositiveInt = Schema.makeFilter((n: number) =\u003e n \u003e 0);\n\n// ✅\nconst PositiveInt = Schema.makeFilter((n: number) =\u003e n \u003e 0, {\n\tidentifier: 'PositiveInt',\n\ttitle: 'Positive integer',\n\tdescription: 'A whole number strictly greater than zero.'\n});\n```\n\n### `require-schema-type-alias`\n\nExported `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.\n\n```ts\n// ❌\nexport const User = Schema.Struct({ id: Schema.String });\n\n// ✅\nexport const User = Schema.Struct({ id: Schema.String });\nexport type User = typeof User.Type;\n```\n\n### `stream-large-files`\n\n`fs.readFile` / `fs.readFileString` load the entire file into memory. For paths whose names suggest unbounded size (`*.log`, `dump.json`, `archive.tar`, `export.csv`, `*.ndjson`, …), use `Stream.fromReadableStream` or `FileSystem.stream` instead.\n\n```ts\n// ❌\nconst text = yield * fs.readFileString('events.log');\n\n// ✅\nconst fs = yield * FileSystem.FileSystem;\nconst lines = fs\n\t.stream('events.log')\n\t.pipe(Stream.decodeText('utf-8'), Stream.splitLines);\n```\n\n### `throw-in-effect-gen`\n\n`throw` inside `Effect.gen` / `Effect.fn` / `Effect.fnUntraced` lands in the unrecoverable defect channel. Use `yield* Effect.fail(new MyError(...))` (or `yield* new MyTaggedError({ ... })`) to keep failures typed. The `try:` arm of `Effect.tryPromise` / `Effect.try` is excluded — that's the point of `try:`.\n\n```ts\n// ❌\nEffect.gen(function* () {\n\tif (!user) throw new Error('User missing');\n\treturn user;\n});\n\n// ✅\nEffect.gen(function* () {\n\tif (!user) return yield* Effect.fail(new UserMissing({ id }));\n\treturn user;\n});\n```\n\n### `use-clock-service`\n\n`new Date()` / `Date.now()` / `Date.UTC()` are non-deterministic and untestable. Use the `Clock` service or the `DateTime` module so tests can freeze time.\n\n```ts\n// ❌\nconst now = new Date();\nconst ms = Date.now();\n\n// ✅\nconst now = yield * DateTime.now;\nconst ms = yield * Clock.currentTimeMillis;\n```\n\n### `use-command-executor-service`\n\n`child_process` / `node:child_process` ties code to Node's process model and yields untyped errors. Use `ChildProcessSpawner` + `ChildProcess` from `effect/unstable/process`, or `Command` + `CommandExecutor` from `@effect/platform`, for typed, scoped, composable process spawning.\n\n```ts\n// ❌\nimport { spawn } from 'node:child_process';\nconst proc = spawn('git', ['status']);\n\n// ✅\nimport { Command } from '@effect/platform';\nconst status = yield * Command.make('git', 'status').pipe(Command.string);\n```\n\n### `use-console-service`\n\n`console.*` writes to stdout / stderr without spans, structured fields, or test capture. Use `Effect.logInfo` / `logError` / `logWarning` / `logDebug` (preferred), or the `Console` service.\n\n```ts\n// ❌\nconsole.log('Fetched user', user.id);\nconsole.error('Failed', err);\n\n// ✅\nyield *\n\tEffect.logInfo('Fetched user').pipe(Effect.annotateLogs('userId', user.id));\nyield * Effect.logError('Failed').pipe(Effect.annotateLogs('cause', err));\n```\n\n### `use-filesystem-service`\n\n`fs`, `node:fs`, and `fs/promises` tie code to Node's runtime. The `FileSystem` service from `@effect/platform` is portable, layer-substitutable, and integrates with `Stream`, `Scope`, and the rest of Effect.\n\n```ts\n// ❌\nimport * as fs from 'node:fs/promises';\nconst text = await fs.readFile(path, 'utf8');\n\n// ✅\nimport { FileSystem } from '@effect/platform';\nconst fs = yield * FileSystem.FileSystem;\nconst text = yield * fs.readFileString(path);\n```\n\n### `use-http-client-service`\n\nDirect `http` / `https` imports give you a raw socket and untyped errors. Use `HttpClient`, `HttpClientRequest`, and `HttpClientResponse` for typed responses, automatic retries, and testable layer substitution.\n\n```ts\n// ❌\nimport * as https from 'node:https';\nhttps.get(url, (res) =\u003e { ... });\n\n// ✅\nconst client = yield* HttpClient.HttpClient;\nconst json = yield* client.get(url).pipe(\n\tEffect.flatMap(HttpClientResponse.schemaBodyJson(Payload))\n);\n```\n\n### `use-path-service`\n\n`node:path` is Posix-or-Windows-flavored depending on the runtime. The `Path` service from `@effect/platform` is explicit about which variant you're using and is testable / mockable.\n\n```ts\n// ❌\nimport * as path from 'node:path';\nconst full = path.join(dir, name);\n\n// ✅\nimport { Path } from '@effect/platform';\nconst path_ = yield * Path.Path;\nconst full = path_.join(dir, name);\n```\n\n### `use-random-service`\n\n`Math.random()` is non-deterministic; tests can't pin it. The `Random` service threads a seed through the program so `Random.nextInt` is reproducible.\n\n```ts\n// ❌\nconst n = Math.floor(Math.random() * 100);\n\n// ✅\nconst n = yield * Random.nextIntBetween(0, 100);\n```\n\n### `use-temp-file-scoped`\n\n`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`.\n\n```ts\n// ❌\nimport { tmpdir } from 'node:os';\nconst dir = path.join(tmpdir(), 'work');\n\n// ✅\nconst fs = yield * FileSystem.FileSystem;\nconst dir = yield * fs.makeTempDirectoryScoped();\n```\n\n### `vm-in-wrong-file`\n\nView 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.\n\n```ts\n// ❌  profile.tsx\nexport interface ProfileVM { ... }\nexport const ProfileVMLive = Layer.effect(...);\n\n// ✅  profile.vm.ts\nexport interface ProfileVM { ... }\nexport const ProfileVMLive = Layer.effect(...);\n```\n\n### `yield-in-for-loop`\n\n`yield*` inside a `for` loop forces sequential execution and hides the iteration intent. `Effect.forEach` is declarative and parallelizable.\n\n```ts\n// ❌\nfor (const id of ids) {\n\tyield * fetchUser(id);\n}\n\n// ✅\nyield * Effect.forEach(ids, fetchUser, { concurrency: 'unbounded' });\n```\n\n---\n\n\n### `maybe-prefix-requires-option`\n\nA field named `maybeX` promises an `Option\u003cX\u003e`. Using the prefix with a plain `T | null`, `T | undefined`, or a Schema optional/nullable field breaks reader expectations.\n\n`maybe*` names should be typed as `Option\u003cT\u003e` in TypeScript and as `Schema.Option(...)` / `Schema.OptionFromNullishOr(...)` in Schema structs. If the value really is nullable rather than optional, rename it to `nullableX`.\n\n### `no-effect-ignore-then-as`\n\n`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.\n\nThe rule also flags `Effect.ignore` on known infallible primitives where there is no error channel to ignore.\n\n### `no-length-comparison`\n\nManual `.length === 0`, `.length \u003e 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`.\n\n### `prefer-array-fromoption-over-option-match-empty`\n\n`Option\u003cA\u003e` to `ReadonlyArray\u003cA\u003e` is `Array.fromOption`. Spelling that as `Option.match({ onNone: () =\u003e [], onSome: (v) =\u003e [v] })` obscures the intent.\n\n### `require-is-prefix-for-boolean-schema-field`\n\nBoolean Schema fields should read as predicates at call sites. Use prefixes such as `is*`, `has*`, `can*`, `should*`, `was*`, or `will*` for `Schema.Boolean` fields.\n\n## Suppression\n\nAll rules respect oxlint's standard disable directives:\n\n```ts\n// oxlint-disable-next-line effect/\u003crule-name\u003e -- reason\n\n/* oxlint-disable effect/\u003crule-name\u003e -- reason */\n// ... block ...\n/* oxlint-enable effect/\u003crule-name\u003e */\n```\n\nA trailing `-- \u003creason\u003e` comment is encouraged for any suppression that lives longer than a single PR review.\n\n## Development\n\n```sh\nbun install\nbun test          # run the test suite (446 tests across 59 rules)\nbun run check     # format + lint + typecheck\n```\n\nEach rule lives in `src/rules/\u003crule-name\u003e.ts` with a sibling test in `test/rules/\u003crule-name\u003e.test.ts`. The rule SDK is documented at [`effect-oxlint`](https://github.com/mpsuesser/effect-oxlint).\n\nThe same rule set is also expressed as a [`pi-effect-harness`](https://github.com/mpsuesser/pi-effect-harness) pattern catalog for ast-grep — the two implementations are kept in alignment.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmpsuesser%2Foxlint-plugin-effect","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmpsuesser%2Foxlint-plugin-effect","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmpsuesser%2Foxlint-plugin-effect/lists"}