@mkrz/oxlint-config
v0.1.1
Published
Opinionated Oxlint ruleset for TypeScript and React
Maintainers
Readme
@mkrz/oxlint-config
@mkrz/oxlint-config is the Oxlint setup I use for TypeScript and React
projects. It is a set of guard-rails, not a starting point: every rule runs at
error severity, and the switches turn whole concerns off, never individual
rules.
Read this first
The ruleset rejects code that most projects accept. Expect the first run on an existing codebase to report a lot.
letis an error, includingfor (let i = 0; …). Useconstand derive a new value; usefor…of.../imports are an error everywhere, inside packages too. Set up path aliases or package imports first.as Tis an error exceptas const. Parse the value with a runtime schema library or narrow it with a type guard.unknownparameters and return types are errors.- With
reacton:useEffect,useLayoutEffect,useInsertionEffect,useMemo,useCallback,useReducer, anduseSyncExternalStoreare errors. - In test files,
vi.mockandjest.mockare errors. The test override relaxes assertions, not mocking. - Disable comments are policed: none at all in applications, one named
oxlint-disable-next-line <rule> -- reasonin libraries and packages. - Test files are assumed to use vitest.
- Blank lines between multi-line statements, blocks, and before
returnare enforced. This is a style rule with an autofix (oxlint --fix), not a correctness check; it does not conflict with oxfmt or Prettier.
Install
pnpm add -D oxlint @oxlint/plugins oxlint-tsgolint @mkrz/oxlint-config@oxlint/plugins is a peer dependency so its version always matches the
oxlint you run. oxlint-tsgolint powers the type-aware rules; without it
the default config warns and runs without them.
Oxlint cannot resolve npm package names from the extends array in
.oxlintrc.json. Use an oxlint.config.ts file and import from this package
instead. Node 22.18 or newer is required because Oxlint loads that file at
runtime. The package has no ESLint dependency; every custom rule, including the
blank-line rule, runs as an Oxlint JS plugin.
Use
Add an oxlint.config.ts file at the root of your project:
import { mkrz } from '@mkrz/oxlint-config';
export default mkrz();mkrz(options?, config?) returns one flat Oxlint root config. The plugin is
declared once, the correctness and suspicious categories are on at error
severity once, and there is no subset that fails to load or runs at warning
severity.
Options
export default mkrz({
react: false,
typeAware: true,
repositoryType: 'monorepo',
appsPath: ['products/**'],
packagesPath: ['modules/**'],
appPackages: ['@acme/shop', '@acme/admin-*'],
});react(defaulttrue): accessibility, React Compiler, hooks, query, and store rules. Turn off for Node services and libraries without JSX.typeAware(defaulttrue): type-checked TypeScript rules. Needs a tsconfig andoxlint-tsgolint, and costs more lint time. Whenoxlint-tsgolintis not installed, the default prints a warning and runs without these rules; an explicittypeAware: truethrows.repositoryType(default'auto'): where the disable-directive policy applies. See below.appsPath,packagesPath, andappPackagesare only accepted together withrepositoryType: 'monorepo'.
Your own settings
Pass your root config as the second argument. Its rules replace this
package's root rules, its overrides run after this package's, and its
ignorePatterns, env,
globals, settings, and plugins are merged with the ruleset's:
import { mkrz } from '@mkrz/oxlint-config';
export default mkrz(
{ react: false },
{
ignorePatterns: ['**/generated/**'],
rules: {
'mkrz/no-let': 'off',
},
overrides: [
{
files: ['scripts/**'],
rules: { 'no-alert': 'off' },
},
],
}
);File overrides take precedence over root rules. To change a rule supplied by one of this package's overrides, add a matching override of your own. For example, to relax the application directive policy:
export default mkrz(
{ repositoryType: 'app' },
{
overrides: [
{ files: ['**/*'], rules: { 'mkrz/no-oxlint-disable': 'off' } },
],
}
);If you compose with Oxlint's extends instead, use the defineConfig export
of this package. Oxlint only honors ignorePatterns, env, globals, and
settings on the root config and drops them from extended configs; the
wrapper folds them back in. The ignore list is also exported as
ignorePatterns.
Type imports
Always use separate top-level import type declarations for types. Inline
type specifiers are forbidden, including in imports that also contain values.
With verbatimModuleSyntax, an all-inline type import leaves a runtime
import {} behind and can fail for packages that contain only declarations.
The config also checks for these unintended runtime dependencies.
Repository types
app: no Oxlint or ESLint disable comment anywhere.library: a single namedoxlint-disable-next-line <rule> -- reasoncomment is allowed.monorepo: theapppolicy underappsPath(defaultapps/**), thelibrarypolicy underpackagesPath(defaultpackages/**). Files outside both, such as root configs, get neither. Packages may not import the workspace package names listed inappPackages(gitignore-style globs). Relative paths that climb out of a package are already rejected byimport/no-relative-parent-imports.auto(the default) reads the directory Oxlint runs in: apnpm-workspace.yamlwithpackages, aworkspacesfield, or anapps/orpackages/directory meansmonorepo. Otherwise"private": trueinpackage.jsonmeansappand anything elselibrary. Without apackage.jsonit falls back toappand says so. A workspace withoutapps/orpackages/is stillmonorepo, but the policy overrides would then match nothing, so it warns and asks for explicitappsPathandpackagesPath. It is the working directory, not the directory ofoxlint.config.ts, because a config file cannot learn its own location from Oxlint; run Oxlint from the repository root or set the type explicitly.
Unused disable directives are always reported. One bypass the directive rules
cannot close themselves: a file-level
/* oxlint-disable mkrz/no-oxlint-disable */ comment suppresses the very rule
that reports it and counts as a used directive. The bare /* oxlint-disable */
form is still caught by unicorn/no-abusive-eslint-disable. Catching the
named form needs an out-of-band check, such as a CI grep for
oxlint-disable mkrz/.
React policy
react rejects useEffect, useLayoutEffect, useInsertionEffect,
useReducer, useSyncExternalStore, useMemo, and useCallback. There is
deliberately no sanctioned way to subscribe to something outside React by
hand; that is what the query and store libraries are for. Oxlint's native
ports of the React Compiler and rules-of-hooks checks run as errors, so no
ESLint plugin is loaded for React. The rule sees hooks imported from react
and called as React.useEffect; a local re-export of React is not followed.
With react on, both the browser and node environments are enabled for
every file. Universal code (server components, loaders) needs both, and file
location cannot tell them apart. Use separate tsconfigs with appropriate
lib and types settings to check environment-specific globals.
Side-effect imports are rejected by import/no-unassigned-import, except
stylesheets (.css, .scss, .sass, .less). Add other side-effect modules
such as server-only through your root config's rules.
Query and store hooks are matched by name, on direct calls only: anything of
the form use*Query, use*Queries, or use*Mutation counts as a query hook,
and a bound zustand hook named after its store, use<Name>Store, counts as a
store hook, so wrapper hooks such as useUserQuery follow the same policy.
Aliases such as const query = useQuery are not followed. Query results
cannot be object-destructured, and a cast or parentheses do not hide the call.
Keeping the result together is a project convention: it makes the source of
each field visible and keeps checks and reads on the same object. TypeScript
can preserve narrowing for const { data, isSuccess } = useQuery() when both
bindings come from the same destructuring declaration. This rule deliberately
rejects that valid form too. For mutations, write
const mutation = useMutation(...) and call mutation.mutate. Store
hooks must receive a selector, and the selector may not return the whole
state. The store rule is written for zustand: the
bare useStore(store, selector) is checked when it is imported from zustand
or zustand/react, and a useStore from any other module (react-redux,
MobX) is left alone. Name bound stores by domain; a local
const useStore = create(...) is not recognised. useSyncExternalStore is
not treated as a store hook (it is rejected by the hook policy above
instead).
Base policy
base is always on. It turns the correctness and suspicious categories on
at error severity for every enabled plugin and lists only the rules outside
those categories. Three of the listed rules (no-unreachable-loop,
typescript/prefer-optional-chain, react/require-render-return) are in
Oxlint's nursery and may be renamed between Oxlint minors.
Test files (**/*.{test,spec}.*, **/*.test-d.*, **/__tests__/**) get the
vitest plugin and may use type assertions and non-null assertions.
Custom rules
The custom rules ship from the same package at @mkrz/oxlint-config/plugin.
mkrz() loads that subpath itself, so consumers do not configure it
separately.
Rules written for this package:
mkrz/no-app-requires: the CommonJS counterpart to monorepo import restrictions.mkrz/no-letmkrz/no-oxlint-disableandmkrz/package-disable-policymkrz/no-query-result-destructuringmkrz/no-restricted-react-hooksmkrz/require-store-selectormkrz/no-type-assertionmkrz/padding-line-between-statements: a port of the@stylisticrule of the same name, with the same options and messages. Oxlint has no esquery, so theselectoroption accepts*or an exact AST node type instead of a full selector.
Rules vendored from Dillon Mulroy's anti-slop project, enabled as errors by default. The assertion and widening checks are relaxed in test files:
mkrz/no-chained-type-assertionsmkrz/no-known-value-wideningmkrz/no-module-mockingmkrz/no-reflect-applymkrz/no-reflect-getmkrz/no-unknown-parametersmkrz/no-unknown-returnsmkrz/no-unknown-type-aliasesmkrz/no-unsafe-dictionary-typemkrz/no-widen-then-assert
Examples and migration guidance for every custom rule are in
the rule reference. Each diagnostic links to its rule there.
The CommonJS boundary rule uses ignore to match the same gitignore-style
patterns as the native ESM restriction. Dynamic CommonJS module names cannot
be checked statically; literal require and module.require calls are checked.
The local changes made to the vendored rules are listed in
src/plugin/rules/vendor/anti-slop/README.md.
Versions
The peer ranges on oxlint, @oxlint/plugins, and oxlint-tsgolint use ~
because Oxlint's JS plugin API is not covered by semver yet; expect a release
of this package per Oxlint minor. This has a consequence for npm users: npm
treats an unmet peer range as an install error, so you cannot move to a new
Oxlint minor until this package has followed (pnpm only warns). Renovate opens
one grouped PR per Oxlint release, and the tarball is installed and linted in
CI before every release.
Maintaining the policy
pnpm test compares the effective native rules, severities, options, and
custom overrides against src/__snapshots__/policy.json. After an intentional
policy or Oxlint update, run pnpm run test:update-policy and review the diff.
pnpm run benchmark measures cold-process linting of a generated TypeScript
consumer with type awareness on and off. See the benchmark notes
for the fixture, measured results, and limits.
License
MIT. The vendored anti-slop code keeps Dillon Mulroy's MIT license and the
blank-line rule keeps the ESLint Stylistic license. The package includes
copies at LICENSE.anti-slop and LICENSE.stylistic.
