Write oxlint custom lint rules with Effect v4.
effect-oxlint wraps @oxlint/plugins in Effect idioms so rule authors get typed errors, composable visitors, Option-safe AST matching, and Ref-based state without any mutable variables.
Rule.define— writecreateas an Effect generator; useyield*for state, context, and diagnosticsVisitor.*— composable visitor combinators:on,onExit,merge,tracked,filter,accumulateAST.*—Option-returning matchers with dual API (data-first and data-last):matchMember,matchCallOf,matchImport,narrow,memberPathDiagnostic.*— structured diagnostic builders with composable autofixesSourceCode.*/Scope.*— effectful queries over tokens, comments, scope, and variablesTesting.*(fromeffect-oxlint/testing) — mock builders, rule runners, and assertion helpers for@effect/vitestPlugin.define— assemble rules into a plugin that oxlint can load
npm install effect-oxlint effect@4.0.0-beta.57bun add effect-oxlint effect@4.0.0-beta.57deno add jsr:@effect-oxlint/effect-oxlintimport * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { AST, Diagnostic, Rule, RuleContext } from 'effect-oxlint';
const noJsonParse = Rule.define({
name: 'no-json-parse',
meta: Rule.meta({
type: 'suggestion',
description: 'Use Schema for JSON decoding instead of JSON.parse'
}),
create: function* () {
const ctx = yield* RuleContext;
return {
// node is typed as ESTree.MemberExpression automatically
MemberExpression: (node) =>
Option.match(
AST.matchMember(node, 'JSON', ['parse', 'stringify']),
{
onNone: () => Effect.void,
onSome: (matched) =>
ctx.report(
Diagnostic.make({
node: matched,
message: 'Use Schema for JSON'
})
)
}
)
};
}
});import { Rule } from 'effect-oxlint';
// Ban a member expression
const noMathRandom = Rule.banMember('Math', 'random', {
message: 'Use the Effect Random service instead'
});
// Ban an import
const noNodeFs = Rule.banImport('node:fs', {
message: 'Use the Effect FileSystem service instead'
});
// Ban a statement type
const noThrow = Rule.banStatement('ThrowStatement', {
message: 'Use Effect.fail instead of throw'
});
// Ban bare identifier calls (e.g. fetch(), useState())
const noFetch = Rule.banCallOf('fetch', {
message: 'Use Effect HTTP client instead'
});
// Ban new expressions (e.g. new Date(), new Error())
const noNewDate = Rule.banNewExpr('Date', {
message: 'Use Clock service instead'
});
// Ban obj.prop(...) method calls (e.g. Effect.runSync, console.log)
const noRunSync = Rule.banCallOfMember('Effect', ['runSync', 'runPromise'], {
message: 'Keep effects composable — run only at the entry point'
});
// Ban multiple patterns with one rule
const noImperativeLoops = Rule.banMultiple(
{
statements: [
'ForStatement',
'ForInStatement',
'ForOfStatement',
'WhileStatement',
'DoWhileStatement'
]
},
{ message: 'Use Arr.map / Effect.forEach instead' }
);
// Combine different ban types in a single rule
const useClockService = Rule.banMultiple(
{
newExprs: 'Date',
members: [['Date', 'now']]
},
{ message: 'Use Clock service' }
);import { Plugin } from 'effect-oxlint';
export default Plugin.define({
name: 'my-effect-rules',
rules: {
'no-json-parse': noJsonParse,
'no-math-random': noMathRandom,
'no-node-fs': noNodeFs,
'no-throw': noThrow
}
});Visitors are Record<string, (node) => Effect<void>> maps. The Visitor module provides combinators to build and compose them.
import { Visitor } from 'effect-oxlint';
const combined = Visitor.merge(importVisitor, memberVisitor, statementVisitor);When two visitors handle the same node type, both handlers run sequentially.
Replace mutable let depth = 0 counters with Visitor.tracked:
import * as Ref from 'effect/Ref';
import { AST, Visitor } from 'effect-oxlint';
const depthRef = yield * Ref.make(0);
const tracker = Visitor.tracked(
'CallExpression',
// node is typed as ESTree.CallExpression
(node) => AST.isCallOf(node, 'Effect', 'gen'),
depthRef
);
// depthRef increments on enter, decrements on exitCollect data during traversal, then analyze at Program:exit:
import { Visitor, AST } from 'effect-oxlint';
const visitor =
yield *
Visitor.accumulate(
'ExportNamedDeclaration',
(node) => AST.narrow(node, 'ExportNamedDeclaration'),
function* (exports) {
// all exports collected — analyze them here
}
);Restrict a visitor to specific files:
import { Visitor } from 'effect-oxlint';
const visitor =
yield *
Visitor.filter((filename) => !filename.endsWith('.test.ts'), mainVisitor);Every matcher returns Option for safe composition with pipe, Option.map, and Option.flatMap. All public matchers support dual API (data-first and data-last).
import { pipe } from 'effect';
import * as Option from 'effect/Option';
import type { ESTree } from 'effect-oxlint';
import { AST } from 'effect-oxlint';
// Data-first (pass a MemberExpression directly)
declare const memberNode: ESTree.MemberExpression;
AST.matchMember(memberNode, 'JSON', ['parse', 'stringify']);
// Data-last (pipe-friendly)
pipe(memberNode, AST.matchMember('Effect', 'gen'));
// Chain: narrow an ESTree.Node, then match
declare const node: ESTree.Node;
pipe(
AST.narrow(node, 'CallExpression'),
Option.flatMap(AST.matchCallOf('Effect', 'gen'))
);
// Extract member path: a.b.c -> Some(['a', 'b', 'c'])
AST.memberPath(memberNode);
// Match imports by string or predicate
declare const importNode: ESTree.ImportDeclaration;
AST.matchImport(importNode, (src) => src.startsWith('node:'));import { Diagnostic } from 'effect-oxlint';
// Basic diagnostic
const diag = Diagnostic.make({ node, message: 'Avoid this pattern' });
// With autofix
const fixed = Diagnostic.withFix(
diag,
Diagnostic.replaceText(node, 'replacement')
);
// Compose multiple fixes
const multiFix = Diagnostic.composeFixes(
Diagnostic.insertBefore(node, 'prefix'),
Diagnostic.insertAfter(node, 'suffix')
);Visitor handlers — and the create generator itself — have a fixed error channel of never. That is, a rule cannot fail via Effect.fail; oxlint's plugin API is synchronous and effect-oxlint bridges it with Effect.runSync, which has no way to surface typed failures.
If a handler needs to run a fallible sub-effect, catch the failure inside the handler and decide how to surface it — typically as a reported diagnostic:
const ctx = yield * RuleContext;
const handler = (node: ESTree.Node) =>
fallibleEffect(node).pipe(
Effect.catch(() =>
ctx.report(
makeDiagnostic({ node, message: 'could not analyse node' })
)
)
);See the JSDoc on Rule.define and EffectHandler in src/Visitor.ts for the full contract.
effect-oxlint re-exports all @oxlint/plugins types so consumers don't need a direct dependency for type imports:
import type { ESTree, OxlintPlugin, CreateRule } from 'effect-oxlint';
// ESTree namespace includes all AST node types
const node: ESTree.CallExpression = /* ... */;effect-oxlint ships a Testing module with mock AST builders, rule runners, and assertion helpers. It's exposed as a dedicated subpath export so production bundles don't pull in test-only code:
import { describe, expect, test } from '@effect/vitest';
import * as Option from 'effect/Option';
import { Rule } from 'effect-oxlint';
import * as Testing from 'effect-oxlint/testing';
describe('no-json-parse', () => {
test('reports JSON.parse', () => {
const result = Testing.runRule(
noJsonParse,
'MemberExpression',
Testing.memberExpr('JSON', 'parse')
);
Testing.expectDiagnostics(result, [{ message: 'Use Schema for JSON' }]);
// Or use the messages() helper — returns Option per diagnostic
expect(Testing.messages(result)).toEqual([
Option.some('Use Schema for JSON')
]);
});
test('ignores other member expressions', () => {
const result = Testing.runRule(
noJsonParse,
'MemberExpression',
Testing.memberExpr('console', 'log')
);
Testing.expectNoDiagnostics(result);
});
});// newExpr accepts a string — auto-wrapped in id()
Testing.newExpr('Date'); // equivalent to Testing.newExpr(Testing.id('Date'))
// ifStmt params are all optional — useful for enter/exit tracking tests
Testing.ifStmt(); // minimal IfStatement node
// program() accepts a comments parameter for comment-based rules
Testing.program(
[Testing.exprStmt(Testing.callExpr('foo'))],
[Testing.comment('Line', ' eslint-disable')]
);Available builders include id, memberExpr, computedMemberExpr, chainedMemberExpr, callExpr, callOfMember, importDecl, newExpr, throwStmt, tryStmt, ifStmt, program, objectExpr, and more.
| Module | Purpose |
|---|---|
Rule |
Core rule builder (define, meta, banMember, banImport, banStatement, banCallOf, banCallOfMember, banNewExpr, banMultiple) |
Visitor |
Composable visitors (on, onExit, merge, tracked, filter, accumulate) |
AST |
Option-returning pattern matchers (matchMember, matchCallOf, matchImport, narrow, memberPath, findAncestor) |
Diagnostic |
Diagnostic construction and autofix helpers |
RuleContext |
Effect service with access to file info, source code, and report |
SourceCode |
Effectful queries: text, tokens, comments, scope, node location |
Scope |
Variable lookup and reference analysis with Option |
Plugin |
define and merge for plugin assembly |
Comment |
Comment type predicates (isLine, isBlock, isJSDoc, isDisableDirective) |
Token |
Token type predicates (isKeyword, isPunctuator, isIdentifier, isString) |
Testing |
Mock builders, runRule, expectDiagnostics, messages for test harnesses — import from effect-oxlint/testing |
bun install # install dependencies
bun run check # lint + format + typecheck (auto-fix)
bun run test # run all tests
bun run typecheck # tsgo type-check only
# Single test file
bunx vitest run test/Rule.test.ts
# By test name
bunx vitest run -t "reports for matching"effect-oxlint is distributed as TypeScript source (no compiled dist/). This keeps source maps, JSDoc, and type information perfectly aligned with the code you import — and it's how JSR prefers packages to ship.
Consumers must use a TypeScript-aware runtime or bundler:
- Bun — works out of the box.
- Deno (via JSR) — works out of the box.
- Node.js with a bundler (Vite, esbuild, webpack, Rollup, tsup, etc.) — works out of the box.
- Node.js directly — run via
tsx,ts-node, or compile your own code; ensuretsconfig.jsonhas"moduleResolution": "bundler"(or"nodenext") so TS resolves the.tsexportsentry.
effect is a peer dependency and must be installed alongside effect-oxlint at a matching version.
See CONTRIBUTING.md.
MIT