@petbee/eslint-config
v5.4.0
Published
Petbee's eslint config
Downloads
8,232
Readme
@petbee/eslint-config
This package provides Petbee's ESLint configuration as an extensible shared config. From 5.3.0 it supports ESLint 10 only (peerDependencies.eslint: ^10.0.0).
⚠ 5.3.0 is a breaking change released as a minor. It is published inside the
^5range on purpose.Move the repo to eslint 10 before regenerating or updating its lockfile.
- Any repo on
@petbee/eslint-config@^5picks up 5.3.0 the next time its lockfile is regenerated or re-resolved, without anyone choosing to: annpm installwith no lockfile, annpm update, a Dependabot or manual bump that targets this package, or deleting and rebuilding the lockfile. A plainnpm installagainst a committedpackage-lock.jsonthat already satisfies^5keeps the locked 5.2.0.- If that repo is still on eslint 9,
npm installfails withERESOLVE(peer eslint@"^10.0.0" from @petbee/eslint-config); yarn v1 does not fail, it only warnsincorrect peer dependencyand installs 5.3.0 anyway, so a yarn repo needs eslint 10 just the same. The fix is to move the repo to eslint 10 — not--legacy-peer-deps,--forceor anoverridesentry.- Node 22+ is required (
engines.node >=22). npm only warns on older Node, but yarn v1 fails to install on Node < 22.- A repo that wants to stay on the old behaviour must pin
~5.2.0(or5.2.0) explicitly.Migration steps and the React rule mapping are in CHANGELOG.md.
Features (v4.0.0)
- ✅ ESLint 10 - the only supported major from 5.3.0; 5.0–5.2 declared
^9.39.1 || ^10.0.0but no eslint version satisfied its own dependencies - ✅ Node 22+ -
engines.node >=22from 5.3.0, because@eslint-react/eslint-plugindeclares it. That includes backend repos: see React rules below - ✅ TypeScript 4/5/6 support - Broad compatibility for incremental upgrades
- ✅ typescript-eslint 8.59+ - Current type-aware linting stack
- ✅ Auto NestJS detection - Automatically configures for NestJS projects
- ✅ Jest 29 support - Updated test linting rules
- ✅ Cypress 6 support - Modern E2E test linting
- ✅ Next.js plugin bundled -
@next/eslint-plugin-nextships with this package - ✅ Prettier integration - Conflicts automatically resolved
Installation
Give that you already have ESLint installed, run:
npm install -D @petbee/eslint-config typescript prettierUsage
For ESLint v8.x and older (legacy config) — 5.2 and earlier only
5.3.0 requires ESLint 10, and ESLint 10 has no .eslintrc support, so this form only applies to a
project still on 5.2 or earlier with ESLint 9 or older. The ./legacy entry still ships in 5.3.0, but
no ESLint version that 5.3.0 installs with can load it.
// .eslintrc
{
"extends": ["@petbee/eslint-config"],
}For ESLint v10 (flat config)
ESLint 10 is required from 5.3.0. Do not import eslint-plugin-react in your own config: its newest release (7.37.5) peers eslint <=9.7 and crashes on ESLint 10 when settings.react.version is 'detect'.
React rules come from @eslint-react/eslint-plugin (plugin key @eslint-react) and apply only to **/*.{jsx,tsx}. They are loaded whenever react resolves from the package, which is not the same as "React projects": Prisma 7 installs react as a peer of @prisma/studio-core, so backend services with Prisma load both React plugins too. Their lint results do not change (they have no .jsx/.tsx), but the plugins must import, so the Node 22+ floor applies to them as well.
// eslint.config.js
import petbeeConfig from '@petbee/eslint-config'
export default [
...petbeeConfig,
// Your custom configurations...
]As any other eslint preset, it's possible to override some rules and configurations. We encourage trying to keep the closest possible to the preset rules, but every project is different and sometimes overriding is needed, use it carefully.
For typescript
The preset will automatically load Typescript rules when dealing with .ts or .tsx files. However, there are some rules that require type-checking. This means that a tsconfig.json, which includes all files supposed to be linted, must be present. If your existing configuration does not include all of the files you would like to lint, you can create a separate tsconfig.eslint.json, at the root of your project, as follows:
// tsconfig.eslint.json
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.tsx", "**/*.js"],
"exclude": [],
}And you should be good to go.
For NestJS
The preset will automatically load NestJS rules when your projects has @nestjs/core installed. However, if you are using NestJS with Typescript, you need to make sure that the tsconfig.json file is present at the root of your project. If you are using a custom tsconfig.json, you can create a separate tsconfig.eslint.json file as follows:
// tsconfig.eslint.json
{
"extends": "./tsconfig.json",
"include": ["**/*.ts", "**/*.tsx", "**/*.js"],
"exclude": [],
}This will ensure that the ESLint parser can find all the files that need to be linted.
For Javascript
Sometimes you want to use modern, not yet officially supported, syntax in your Javascript files, such as dynamic import(). This can be achieved by using the babel-eslint parser. For size reasons, we don't include it in this preset but it's extremely simple to configure it:
npm install -D babel-eslint// .eslintrc
{
"extends": "@petbee/eslint-config",
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
},
}If a project uses both Typescript and Javascript, you can configure the parser inside an override block:
// .eslintrc
{
"extends": "@petbee/eslint-config",
"overrides": [
{
"files": ["*.js", "*.jsx"],
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
},
},
],
}Please check the babel-eslint documentation for further options.
How the flat config is assembled
index.mjs composes the rule sets in rules/ through getFlat(), which reads each module's
module.exports.flat. A rule file without that export contributes nothing, silently — no error,
no warning, the rules are simply not there. That is how five files sat inert from v4.0.0 until v5.
npm run test:flat-config now fails if any rule file stops reaching the config.
Order matters, because in flat config the last matching config object wins each rule. The
TypeScript set is applied last so its deliberate tuning (rules/shared-config.js) wins over the
language-agnostic sets, and so the base rules superseded by type-aware ones stay off on .ts files.
A new rule set goes before it.
Import order
This package owns import order, through the import/order rule. @petbee/prettier-config does not
touch it — up to v4 it loaded prettier-plugin-organize-imports, whose alphabetical ordering
contradicted this rule and made the resulting error unfixable; the plugin was removed in v5.
Order is enforced as blank-line separated groups, and eslint --fix produces it:
import { createHash } from 'node:crypto'
import { Injectable } from '@nestjs/common'
import { PutCommand } from '@aws-sdk/lib-dynamodb'
import { buildHelmetConfig } from '@config/security'
import { DynamoDbClientProvider } from './dynamodb.client'
import type { SuppressionRecord } from './suppression.types'Reaching outside the module boundary is enforced by no-restricted-imports at error (pattern
../../*). Fix those with a path alias, not by relaxing the rule.
Unused imports are reported by @petbee/no-unused-vars (on the flat path; see below) at error.
Prefix an intentionally unused binding with _.
@petbee/max-params
This package ships two rules of its own, under the @petbee plugin name: this one and
@petbee/no-unused-vars. Each is an upstream rule plus one exemption that
configuration cannot express.
@petbee/max-params is max-params plus a single exemption: a constructor whose parameters are
all TypeScript parameter properties is not counted.
// Not reported — 5 parameter properties is constructor injection.
constructor(
private readonly a: A,
private readonly b: B,
private readonly c: C,
private readonly d: D,
private readonly e: E
) {}
// Still reported — plain parameters.
constructor(a: A, b: B, c: C, d: D, e: E) {}
// Still reported — mixing injection with plain parameters is not injection.
constructor(private readonly a: A, b: B, c: C, d: D, e: E) {}
// Still reported — every non-constructor function is checked as before.
function build(a: A, b: B, c: C, d: D): void {}NestJS constructor injection is the mandated pattern for every service in the fleet and routinely
takes more than three dependencies, so a limit of 3 was wrong fleet-wide rather than wrong in one
repo. The limit itself is unchanged at 3 — raising it would have silenced the rule everywhere,
which is the opposite of what was wanted. A parameter property is precisely a constructor parameter
carrying an accessibility modifier, readonly, or override, which the parser reports as a
TSParameterProperty node, so that node type is the whole test.
The exemption cannot be expressed in configuration: neither max-params nor
@typescript-eslint/max-params has an option for it (both take only max, maximum and
countVoidThis), and ESLint config selects by file, never by node. lib/plugin.js therefore wraps
the base rule's listeners and drops the exempt node — the same technique
@typescript-eslint/max-params uses to wrap the core rule — so the severity, the limit, the options
schema, the set of visited node types and the report messages are all unchanged.
On the flat path core max-params is off and @petbee/max-params carries the rule. The legacy
eslintrc export (@petbee/eslint-config/legacy, ESLint < 9) keeps plain max-params, because that
path cannot load a plugin this package does not publish separately.
Upgrading: if you have // eslint-disable-next-line max-params anywhere, rename it to
// eslint-disable-next-line @petbee/max-params. Left alone it reports twice — once as an unused
disable directive, once as the rule it no longer suppresses. Both are warnings, so nothing fails.
Constructor-injection sites can drop the comment entirely.
npm run test:max-params asserts all of the above against a real fixture, so the exemption cannot go
inert the way the five rule files did in v4.
@petbee/no-unused-vars
@typescript-eslint/no-unused-vars plus a single exemption: a value that is read only through
typeof is not reported when it merges with an exported type alias or interface of the same
name. That is the Prisma include/select idiom in the fleet's *.interfaces.ts files:
// Not reported — the value exists so `typeof` can read a checked args object, and the exported
// type of the same name is how the rest of the codebase reaches it.
const PetsFull = { include: { petFamilies: true } } satisfies Prisma.PetsDefaultArgs
export type PetsFull = Prisma.PetsGetPayload<typeof PetsFull>
// Still reported (usedOnlyAsType) — no type of the same name is exported.
const PetsWithOwner = { include: { owner: true } } satisfies Prisma.PetsDefaultArgs
export type PetsWithOwnerPayload = Prisma.PetsGetPayload<typeof PetsWithOwner>
// Still reported (unusedVar) — the same-name type is exported, but nothing reads the value.
const Orphan = { include: { owner: true } } satisfies Prisma.PetsDefaultArgs
export type Orphan = string
// Still reported (usedOnlyAsType) — `TypeHalf` in the second alias names the exported string type,
// not the value. Upstream counts it as a type-position use of the merged binding, but no `typeof`
// ever reads the value, so it is dead.
const TypeHalf = { include: { owner: true } } satisfies Prisma.PetsDefaultArgs
export type TypeHalf = string
export type UsesTypeHalf = TypeHalfUp to typescript-eslint 8.71.0 the exported type counted as an export of the merged value, so the
first case was never reported. 8.71.1
(typescript-eslint#12892, fixing
#10658) stopped crediting the
type's export to the value, and every such value became 'X' is assigned a value but only used as a
type at error — 46 across five services (20 of them in petbee-api-v2) the moment their lockfiles
float to 8.71.1. Upstream treats that as a bug fix, not a regression, so no upstream release will undo it,
and the rule has no option for it: its options select by name pattern or by kind of binding, never by
whether the binding merges with an exported type.
The exemption is deliberately narrow. A report is dropped only when all four hold:
- it is
usedOnlyAsType(the binding is referenced, in type positions); - the variable has a
const/let/vardefinition; - the same variable has a
typeorinterfacedefinition that is itself exported; - at least one reference is a
typeof Xtype query, so something actually reads the value.
Upstream's usedOnlyAsType also counts a plain type reference such as let y: X. On a merged binding
that reference resolves to the type half, which is why the last condition exists. These are all still
reported, exactly as upstream reports them:
- a value that is never referenced;
- a value used only as a type with no exported type of the same name;
- a value merged with a type that is not exported;
- a merged value that no
typeofreads.
The rule wraps the upstream rule's context.report rather than reimplementing it, so the options
schema, the default options, the messages, the fixes and the suggestions are identical. Against a
typescript-eslint older than 8.71.1 the exemption never fires, because the upstream rule never makes
that report.
On the flat path @typescript-eslint/no-unused-vars is off and @petbee/no-unused-vars carries
the rule at the same severity with the same options. The legacy eslintrc export keeps
@typescript-eslint/no-unused-vars, for the same reason it keeps plain max-params.
Upgrading:
- If you have
// eslint-disable-next-line @typescript-eslint/no-unused-varsanywhere, rename it to// eslint-disable-next-line @petbee/no-unused-vars. Unlikemax-params, this one can fail lint: left alone the directive no longer suppresses anything, so it is reported as an unused directive (a warning) and the binding it covered is reported by@petbee/no-unused-varsaterror. - If your
eslint.config.*sets'@typescript-eslint/no-unused-vars', set'@petbee/no-unused-vars'instead. Left alone, the old entry does not do what it says:'off'or'warn'(to relax the rule) is silently ignored. It configures a rule this package no longer runs, and@petbee/no-unused-varskeeps reporting aterrorwith this package's options. With'warn'the upstream rule also comes back on, so each binding is reported twice: once as a warning and once as an error.'error'with custom options turns the upstream rule back on next to this one. Every unused binding is reported twice, and the merged-declaration exemption is undone.
npm run test:no-unused-vars asserts the wiring, the exact set of reports on an eight-case fixture,
and — so the exemption cannot pass vacuously — that the upstream rule with the same options does
report the two exempt cases on the typescript-eslint this repository installs.
