@aurelienbbn/oxlint-config
v0.8.0
Published
Opinionated strict reusable oxlint config for TypeScript projects.
Maintainers
Readme
@aurelienbbn/oxlint-config
Strict oxlint for TypeScript: every stable category at error, type-aware, warnings denied, every rule conflict settled.
pnpm add -D @aurelienbbn/oxlint-config oxlint oxlint-tsgolint
# oxlint >=1.82.0 <2.0.0 · oxlint-tsgolint ^7.0.2001 · Node ^22.19.0 || ^24.11.0// oxlint.config.ts
import { defineStrictOxlintConfig } from "@aurelienbbn/oxlint-config";
import { defineConfig } from "oxlint";
export default defineConfig(
defineStrictOxlintConfig({
ignorePatterns: ["dist/**"],
}),
);Returns a plain OxlintConfig: works with oxlint directly or as Vite+'s lint config, no Vite+ dependency.
1 preset, 5 opt-ins
| Export | Adds |
| ------------------------------------------------------- | ---------------------------------------------------- |
| strictOxlintConfig | the preset object |
| defineStrictOxlintConfig(overrides?, options?) | preset + your overrides, cloned |
| withTanstackQueryLayer(config?, options?) | 🔌 official @tanstack/eslint-plugin-query, 7 rules |
| withEffectTsgoLayer(config, { preset }) | 🔌 official @effect/tsgo preset + 4 owner rules |
| withImportGraphLayer(config?, { entrypoints? }) | 🔌 import/no-cycle + import/no-self-import |
| layerDirectionOverride({ files, forbidden, message }) | 🔌 one overrides entry banning cross-layer imports |
| nurseryCandidateRules | 🔌 4 nursery rules to trial |
Also: testFileGlobs, vagueTestTitlePattern, tanstackQueryRules, tanstackQueryPluginSpecifier, effectTsgoPluginName, effectTsgoOwnerRules, effectTsgoSettledRules, effectIdiomRules, importGraphRules, types OxlintConfig, ImportGraphLayerOptions.
What the preset sets
correctness ██████ error style ██████ error
pedantic ██████ error suspicious ██████ error
perf ██████ error nursery ░░░░░░ off (pending upgrade review)
restriction ██████ errorvitest never runs on source files, only on testFileGlobs: vitest/require-hook reports ordinary top-level calls.
| Key | Value |
| -------------- | ---------------------------------------------------------------------------------------------------- |
| plugins | eslint, typescript, unicorn, oxc, node, promise |
| options | typeAware: true, typeCheck: true, denyWarnings: true, reportUnusedDisableDirectives: "error" |
| env | builtin: true, node: true |
| overrides[0] | vitest plugin on testFileGlobs = **/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs} |
plugins replaces oxlint's default set, so the four defaults (eslint, typescript, unicorn, oxc) are re-listed before node and promise. Other test layout: add an overrides entry with plugins: ["vitest"].
| Rule | Setting | Why |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| typescript/ban-ts-comment | ts-ignore banned, ts-expect-error needs description | a suppression says why |
| typescript/no-explicit-any, typescript/no-non-null-assertion | error | pinned by name |
| unicorn/filename-case | kebabCase | one naming scheme |
| eslint/no-underscore-dangle | allow _tag, _tree, __dirname | tagged unions, Node globals |
| eslint/max-lines | 300, blank and comment lines skipped | measures code, not docs or formatter reflow |
| eslint/sort-keys | off | key order carries meaning: schema fields, table columns, read-in-order config |
| eslint/one-var | never | grouping makes later edits noisier |
| oxc/no-async-await, oxc/no-optional-chaining, oxc/no-rest-spread-properties | off | ban modern syntax, not defects |
| vitest/no-standalone-expect | off (test files) | |
| typescript/no-unnecessary-condition | error by name (nursery off) | needs strictNullChecks; noUncheckedIndexedAccess keeps indexed access quiet |
| typescript/switch-exhaustiveness-check | allowDefaultCaseForExhaustiveSwitch: false, requireDefaultForNonUnion: true | union switch: every member, no default; other switch: default required |
nurseryCandidateRules (not in the preset; spread into rules to trial): typescript/prefer-optional-chain, eslint/no-useless-assignment, eslint/no-unreachable-loop, promise/no-return-in-finally.
Every conflict is settled once
| ❌ Off | ✅ Kept | Why |
| --------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- |
| eslint/no-ternary | unicorn/prefer-ternary, eslint/no-nested-ternary | no-let code needs expressions; nesting stays banned |
| node/no-top-level-await | unicorn/prefer-top-level-await | app entry points; libraries re-enable the node rule |
| vitest/no-importing-vitest-globals | vitest/prefer-importing-vitest-globals | imports stay visible to import-keyed rules |
| vitest/prefer-to-be-truthy, vitest/prefer-to-be-falsy | vitest/prefer-strict-boolean-matchers | toBe(true) is stricter |
| eslint/no-undefined | unicorn/no-null, unicorn/no-useless-undefined | banning both null and undefined leaves no way to express absence |
| eslint/no-warning-comments | — | rejects tracked TODO(#123):; core/no-dead-comments reports untracked only |
| eslint/max-lines-per-function | eslint/max-statements | counts physical lines, so a narrower print width fails it; statements don't |
| eslint/default-case | typescript/switch-exhaustiveness-check | default hides new union members |
| eslint/require-await | typescript/require-await | typed extension |
| eslint/no-implied-eval | typescript/no-implied-eval | typed extension |
| eslint/prefer-promise-reject-errors | typescript/prefer-promise-reject-errors | typed extension |
| eslint/no-throw-literal | typescript/only-throw-error | typed extension |
| unicorn/prefer-includes | typescript/prefer-includes | typed duplicate |
| unicorn/prefer-string-starts-ends-with | typescript/prefer-string-starts-ends-with | typed duplicate |
| unicorn/prefer-array-find | typescript/prefer-find | typed duplicate |
| eslint/no-magic-numbers (test files only) | vitest/prefer-expect-assertions | expected values are the oracle and stay literal; expect.assertions(n) too |
[!WARNING] The typed side wins only because
typeAware: true. Turn it off: re-enable the untyped rules.
Vague test titles fail
it("works", …) // ❌ bare "works" / "should work", any case
it("adds items correctly", …) // ❌ correctly / properly / as expected
it("rejects improperly signed tokens", …) // ✅Pinned so an upstream category move can't drop them:
| Rule | Owns |
| --------------------------------- | --------------------------------------------------------------------------------------------------- |
| vitest/require-to-throw-message | bare toThrow() / rejects.toThrow() (core/no-weak-test-assertions skips this) |
| vitest/prefer-called-with | bare toHaveBeenCalled(): makes existing interaction assertions exact, doesn't create them |
| vitest/valid-title | mustNotMatch: vagueTestTitlePattern |
| vitest/require-hook | top-level setup; allowedFunctionCalls admits the four Harness *Conformance({ root }) registrars |
Whether to assert a call at all: agentlint core/test-behavior-coverage. toHaveBeenCalledWith(expect.anything()): core/no-weak-test-assertions.
| mustNotMatch form (oxlint 1.82.0) | Fires? |
| -------------------------------------- | ---------------- |
| plain string | ✅ |
| { it: "<regex>" } | ✅ |
| [regex, message] tuple | ✅ |
| any form + non-empty disallowedWords | ❌ ignored |
| pattern with \b or \W | ❌ never matches |
So filler words share one pattern with [^a-z] word edges. Overriding: keep one mustNotMatch, leave disallowedWords unset. src/index.test.ts runs the installed oxlint against these cases.
Opt-in layers
import {
defineStrictOxlintConfig,
layerDirectionOverride,
withImportGraphLayer,
withTanstackQueryLayer,
} from "@aurelienbbn/oxlint-config";
import { defineConfig } from "oxlint";
export default defineConfig(
withImportGraphLayer(
withTanstackQueryLayer(
defineStrictOxlintConfig({
overrides: [
layerDirectionOverride({
files: ["**/domain/**"],
forbidden: ["**/infra/**", "express"],
message: "Domain never imports infrastructure.",
}),
],
}),
),
),
);Rules already set on config win over any layer.
[!WARNING] oxlint replaces rule options, it doesn't merge them. Two
overridesmatching one file: the latereslint/no-restricted-importswins, and either replaces a root-level one. One entry per layer glob, listing everything it must not reach.
pnpm add -D @tanstack/eslint-plugin-query # not a dependency of this packageAppends the plugin to jsPlugins, sets tanstackQueryRules to error.
| @tanstack/query/… | Reported in probe¹ |
| ------------------------------- | ------------------------------------------------------------------- |
| exhaustive-deps | ✅ |
| mutation-property-order | ✅ |
| no-rest-destructuring | ✅ (needs type info under oxlint? unconfirmed) |
| prefer-query-options | ✅ |
| stable-query-client | ✅ |
| infinite-query-property-order | ⚠️ not exercised |
| no-unstable-deps | ⚠️ not exercised |
| no-void-query-fn | ❌ left out: needs the TS checker, not exposed to oxlint JS plugins |
¹ @tanstack/eslint-plugin-query 5.103.1 under oxlint 1.82.0, one-off. Wiring, not verified coverage: no repo fixture installs the official plugin; oxlint JS plugins are alpha. Re-check after upgrading either.
options.companionPlugins: { specifier, rules }[]. Specifier appended to jsPlugins after the official plugin; rules merge after the official rules.
pnpm add -D @effect/[email protected] [email protected] [email protected] [email protected] # not dependencies of this package// package.json: registers the native `effecttsgo` plugin in oxlint-tsgolint
{ "scripts": { "prepare": "effect-tsgo patch --oxlint" } }// oxlint.config.ts
import { recommended } from "@effect/tsgo/oxlint-presets";
import { defineStrictOxlintConfig, withEffectTsgoLayer } from "@aurelienbbn/oxlint-config";
import { defineConfig } from "oxlint";
export default defineConfig(withEffectTsgoLayer(defineStrictOxlintConfig(), { preset: recommended }));Merges the preset's rules, adds effecttsgo to plugins, then effectTsgoOwnerRules, effectTsgoSettledRules and effectIdiomRules. Config rules and options win. The preset reports at warn; the strict preset's denyWarnings makes those blocking. Editor LSP (@effect/language-service in tsconfig.json plugins): set "diagnostics": false, or every finding shows twice.
[!WARNING] Version lock.
@effect/tsgo0.45.0 supports oxlint 1.81.0 / 1.82.0, oxlint-tsgolint 7.0.2001, TypeScript 7.0.2, andeffect-tsgo patchrefuses anything else. That is the compatibilitybaselinerow; thecurrentrow (oxlint 1.83.0, oxlint-tsgolint 7.0.2002) is outside it. Pin the baseline row until a tsgo release widens its matrix.
effectTsgoOwnerRules: off in recommended, on here because they own checks @aurelienbbn/oxlint-plugin-effect removed.
| effecttsgo/… | Owns the removed rule |
| ------------------------------ | ------------------------------------------------------------------------------------------ |
| any-unknown-in-error-context | no-unsafe-error-channel |
| unsafe-effect-type-assertion | no-effect-type-assertion |
| deterministic-keys | matching-identifier (with class-self-mismatch, already in recommended) |
| strict-effect-provide | ⚠️ partial: Effect.provide outside entry points, not the removed Layer.provide nesting |
Every overlap with @aurelienbbn/oxlint-plugin-effect, settled once:
| effecttsgo/… | Harness rule | Setting | Why |
| -------------------------------------------------------- | -------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| catch-die-to-or-die | no-effect-ordie | off | its rewrite is Effect.orDie, which the Harness rule bans; the dying catch already reports |
| redundant-or-die | no-effect-ordie | off | hoists an orDie the Harness rule already reports |
| catch-to-or-else-succeed | no-swallowed-failure | off | its rewrite Effect.orElseSucceed(() => placeholder) is reported too |
| catch-to-ignore | no-swallowed-failure, no-catch-all-cause | off | suggests bare Effect.ignore (needs { log }) or Effect.ignoreCause (banned) |
| global-fetch, global-fetch-in-effect | require-abort-signal | both on, tsgo first | replace fetch with HttpClient (interruption built in) and both go quiet; forwarding signal alone leaves tsgo firing. Keeping fetch: turn the tsgo pair off |
| global-error-in-effect-failure, extends-native-error | require-tagged-effect-fail | both on | same fix (a tagged error); Effect.fail(new Error(…)) reports twice. Literals and strings: Harness only |
| try-catch-in-effect-gen, global-timers-in-effect | no-unsafe-effect-body | tsgo owns | the Harness rule keeps only its throw check |
Not running @aurelienbbn/oxlint-plugin-effect? The four off rules lose their reason: set them back in rules.
effectIdiomRules: strict-preset rules that contradict @effect/tsgo or an idiom Effect's API requires. The Effect side wins; each rule still reports outside that idiom.
| Strict rule | Setting | Why |
| ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| typescript/promise-function-async | off | its autofix adds async, which effecttsgo/async-function reports |
| eslint/new-cap | capIsNew: false | Effect constructors are PascalCase functions: Schema.Struct(…) |
| eslint/func-names | generators: "never" | Effect.gen(function* () { … }) takes an anonymous generator |
| node/no-sync | ignores: ["runSync"] | Effect.runSync runs an Effect at an edge; fs.*Sync still reports |
| eslint/max-classes-per-file | off | tagged errors, Schema.Class models and services are classes; a domain module holds several |
| eslint/no-redeclare | off | a Schema and its type share a name (const User + type User); TypeScript rejects real redeclarations |
| unicorn/throw-new-error | off | reports the Schema.TaggedError<Self>()(…) factory; its autofix emits code that does not compile |
| typescript/prefer-readonly-parameter-types | methods readonly, inferred types ignored, Effect's immutable types allowed | the deep check rejects every Effect, Option, DateTime.Utc, Redacted, SQL Fragment and Migrator.Loader parameter; mutable arrays and objects still report |
| unicorn/no-array-callback-reference, unicorn/no-array-for-each, unicorn/no-array-method-this-argument, unicorn/no-array-sort | off | with namespace imports they take Effect modules for arrays (Option.some(value), Effect.forEach, Arr.filter(xs, f), Arr.sort(order)); the effect/no-array-* rules of @aurelienbbn/oxlint-plugin-effect report the same misuse on real arrays. Not running that plugin: set the four back in rules |
Credit: preset shape, the effecttsgo plugin name, and every diagnostic name come from Effect-TS/tsgo (MIT).
One overrides entry using eslint/no-restricted-imports patterns. No default layer map: no layers, no rule.
| Field | Takes |
| ----------- | -------------------------------------------------------------------------------------------------- |
| files | globs of the protected layer |
| forbidden | gitignore-style globs for paths, bare names for packages; relative and package imports both report |
| message | the fix direction: the rule can't know which of the three moves is right |
Cycles creep in through convenience imports between siblings, then surface as undefined at module init and untestable modules.
import rules in oxlint 1.82.0 33
error ██ 2 import/no-cycle, import/no-self-import
off ███████████████████████ 31 pinned off in importGraphRulesLoading the import plugin also builds the module graph, which lets oxc/no-barrel-file count what a barrel loads: a package's src/index.ts over Effect modules loads hundreds. Pass its entry points as entrypoints (withImportGraphLayer(config, { entrypoints: ["packages/*/src/index.ts"] })) to exempt them.
Adding "import" to plugins alone switches on all 33 (whole categories are enabled), including contradicting pairs: import/no-default-export vs import/prefer-default-export, import/no-named-export, import/group-exports. Want another: set it in rules.
| Behavior | Detail |
| ---------------- | ------------------------------------------------------------- |
| type-only cycles | ignored (ignoreTypes): gone at runtime |
| cost | resolves files project-wide, the plugin's most expensive rule |
| path aliases | resolve only when oxlint finds the declaring tsconfig |
After every oxlint upgrade, a new import/* rule missing from importGraphRules turns on via its category:
- [ ]
pnpm exec oxlint --rules(or--rules -f json) - [ ] compare
importrows withimportGraphRules - [ ] pin each new rule
off
Here src/index.test.ts fails until the list matches the installed oxlint. Consumers on another oxlint check by hand.
Contract & migration
| Topic | Contract |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| target | Node/TypeScript. Browser: override env.node, enable the browser env |
| nursery | off pending upgrade review, except typescript/no-unnecessary-condition |
| Jest-namespace disables | ⚠️ removed |
| lists | plugins, jsPlugins, ignorePatterns, overrides merge deduplicated; { replaceLists: true } replaces any list you supply |
| objects | categories, env, options, rules, settings merge key by key |
| isolation | nested rule options are independent clones |
| types | from oxlint; Vite+ re-exports the same contract |
| compatibility | matrix tests the packed config on baseline and current runners, type-aware included |
