Opinionated ESLint rules for elegant, behavior-rich TypeScript. The plugin pushes code toward intention-revealing functions, honest types, and encapsulated domain models — the kind of constraints that pay off in NestJS services and DDD-style codebases.
npm install --save-dev @tianjos/eslint-plugin-elegantpnpm add -D @tianjos/eslint-plugin-elegantyarn add -D @tianjos/eslint-plugin-elegantThis plugin does not bundle ESLint or the TypeScript toolchain. Install them alongside it:
| Peer | Required version |
|---|---|
eslint |
>=9 |
typescript |
>=5 |
@typescript-eslint/parser |
>=8 |
npm install --save-dev eslint typescript @typescript-eslint/parserThis plugin targets flat config (eslint.config.mjs). The fastest way to
adopt it is to spread the recommended ruleset:
// eslint.config.mjs
import parser from '@typescript-eslint/parser';
import elegant from '@tianjos/eslint-plugin-elegant';
export default [
{
files: ['src/**/*.ts'],
languageOptions: {
parser,
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
},
plugins: { elegant },
rules: {
...elegant.configs.recommended.rules,
},
},
];Adopting this on a codebase that already exists? Spread
elegant.configs.starter instead — same rules, with the four heaviest demoted
so the first run gives you a list you can work through. See
Adopting on an existing codebase.
Two more configs exist for the files a preset should not judge the same way —
tests and off. See Relaxing rules in test files
and Generated and scaffolded files.
A complete, copy-pasteable example (including those overrides) lives in
eslint.config.example.mjs.
The plugin exports four configs. Two are presets — recommended and starter
— carrying every rule below plus two native ones, max-params and
no-else-return:
recommended— the severities in the table below. What the plugin argues for.starter— the same rules with the four heaviest demoted, for adopting on a codebase that already exists. See Adopting on an existing codebase.tests— an override, not a preset: the eight rules a spec legitimately trips, off. See Relaxing rules in test files.off— an override too: every rule disabled, for generated files. See Generated and scaffolded files.
| Rule | Source | What it catches | recommended |
|---|---|---|---|
elegant/no-boolean-param |
custom | Boolean parameters (flag arguments) on functions, methods, and constructors | error |
elegant/max-class-methods |
custom | Classes with more methods than the configured max (constructors excluded) |
warn (max 10) |
elegant/max-class-dependencies |
custom | Classes depending on more distinct collaborators than max (constructor injections plus new) |
warn (max 4) |
elegant/max-class-fields |
custom | Classes holding more instance fields than max (declared fields plus parameter properties) |
warn (max 5) |
elegant/no-type-assertion |
custom | value as T, <T>value, and value! assertions (as const is allowed) |
error |
elegant/no-any-return |
custom | any (or Promise<any>) declared as a function's return type |
error |
elegant/no-null-return |
custom | return null statements |
error |
elegant/no-public-mutable-props |
custom | Public, non-readonly class properties and public constructor parameter props |
error |
elegant/no-logic-in-constructor |
custom | Any constructor code beyond this.field = value stores and a super(...) call |
error |
elegant/no-getters-setters |
custom | get/set accessors (and getX/setX methods with { methods: true }) |
error |
elegant/no-instanceof |
custom | Use of the instanceof operator |
error |
elegant/no-static-members |
custom | Static methods, properties, accessors, and blocks (secondary constructors and Nest module factories excepted) | error |
elegant/no-null |
custom | The null literal as a value (type annotations and direct return null excepted) |
error |
elegant/no-comments-in-function-body |
custom | Comments inside function bodies (directives and empty blocks excepted) | error |
elegant/no-else-after-throw |
custom | An else branch when the then branch always throws |
error |
elegant/no-interpolated-log-message |
custom | Log messages built by interpolation or concatenation | error |
elegant/max-returns |
custom | Functions returning from more places than max |
warn (max 3) |
elegant/no-property-alias |
custom | Locals that only rename a property of an object already in hand | error |
elegant/no-property-destructuring |
custom | Destructuring an object already in hand into locals | error |
elegant/no-anonymous-param-type |
custom | Parameters typed as an anonymous shape of minMembers or more properties |
error (minMembers 2) |
elegant/no-self-mutation |
custom | Writing to your own fields outside the constructor | error |
elegant/no-generic-error |
custom | throw new Error(...) and the other built-in error types |
error |
elegant/max-method-lines |
custom | Named functions and methods longer than max lines |
warn (max 50) |
max-params |
native | Functions declaring more than max parameters |
warn (max 3) |
no-else-return |
native | An else branch when the then branch always returns (allowElseIf: false) |
error |
A boolean argument almost always means the callee does two things. Prefer two
intention-revealing functions or an options object. Flags both annotated
(flag: boolean) and boolean-defaulted (flag = false) parameters.
A proxy for the Single Responsibility Principle. Constructors are not counted;
getters and setters are. Configurable via { max: number } (default 10).
The coupling counterpart to max-class-methods: a class that needs six
collaborators to do its job is coordinating, not modelling. Counts the distinct
types annotated on constructor parameters plus every type instantiated with
new inside the class body — so a dependency hidden behind new HttpClient()
weighs the same as an injected one.
De-duplication keeps type arguments, so Repository<Order> and
Repository<Customer> count as two collaborators while the same Clock
injected twice counts as one. Nested classes are budgeted independently of their
host.
Three things never count: primitives and inline types (they are not type
references), a default list of ambient built-ins (Date, Map, Set,
Promise, Error, Array, RegExp, URL, WeakMap, WeakSet), and
exceptions raised with throw new .... That last exclusion is what makes the
rule usable in NestJS, where throw new NotFoundException() is routine and says
nothing about a class's design.
Configurable via { max: number, ignore: string[] } (default max: 4).
ignore adds to the built-in list — reach for it when an ambient concern such
as Logger or ConfigService is in every constructor and you would rather not
budget for it:
'elegant/max-class-dependencies': ['warn', { max: 4, ignore: ['Logger'] }],The third axis of class size, after methods and collaborators: a class carrying
a dozen fields is a record with a namespace, not a model. Counts instance fields
declared in the body — plain, abstract, or accessor — plus every constructor
parameter property. Methods and accessors belong to max-class-methods, and
static members to no-static-members, so neither is counted here.
Decorated properties are skipped by default. @Column, @IsString and
@ApiProperty map a field to a table or a payload, so a DTO or an ORM entity
declares one field per column by design and has no business inside a budget:
class CreateOrderDto {
@IsString() customerId: string; // not counted
@IsInt() quantity: number; // not counted
}The exemption stops at the constructor. A decorator on a parameter is
injection, not mapping, so @Inject(TOKEN) private readonly repo: Repo stays
inside the budget — otherwise a service wired entirely through tokens would
count zero fields, which is exactly the class the rule exists to catch. Set
{ ignoreDecorated: false } to budget mapped properties too.
Configurable via { max: number, ignoreDecorated: boolean } (default max: 5,
ignoreDecorated: true). The default leaves room for the four collaborators
max-class-dependencies allows plus one field of genuine state; past that the
two rules deliberately overlap, because a class over both budgets is over-sized
on both axes.
Assertions silence the type checker. Reach for a type guard, a generic, or a
correctly typed value instead. as const is permitted because it narrows rather
than widens.
All three syntactic forms are the same act, so all three are reported: value as T, <T>value, and the non-null operator value!. The last one is the one
worth naming, because it is the cheapest to type and the most expensive to be
wrong about — entity.rate! compiles whether the column is nullable, whether
the driver hands back a string, or whether the row simply has no value. Narrow
it with a check that throws, or correct the type if it was never nullable:
// reported
const rate = origin.subsequentRate!;
// intended
const requireRate = (origin: Origin): number => {
if (origin.subsequentRate === undefined) {
throw new MissingRateError(origin.code);
}
return origin.subsequentRate;
};Pairs with no-any-return, which closes the way around it.
A function whose declared return type is any widens every value that passes
through it. That is a type assertion — the caller writes const body: T = parse(raw) and the checker agrees — except it is invisible: as T is
greppable at the call site, an any return is not.
This is the shape no-type-assertion pushes code into if nothing catches it.
The cast does not disappear; it moves one call deeper and stops being reviewable.
// reported — every caller's type is asserted for them
const readJson = async (response: Response): Promise<any> => response.json();
// intended — the caller narrows, or supplies the type it is claiming
const readJson = async (response: Response): Promise<unknown> => response.json();
const request = async <T>(path: string): Promise<T> => fetch(path).then(parse);Return position only. any on a parameter is a different (lesser) defect and
belongs to @typescript-eslint/no-explicit-any;
this rule stays narrow so it can ship in the preset without requiring
type-aware linting. Promise<any> counts, because awaiting it is not a
narrowing step.
Keeps absence out of return values. Throw when the value must exist, or return an object that answers for the absent case — a null object, a domain type with a "nothing found" state.
An empty collection models absence only where the return type was already a
collection. Wrapping a single value in a zero-or-one array to dodge this rule
is a null in a box: the type now promises a list it will never have more than
one of, and every caller loops over something that is really an if.
// reported
function decide(status: number): Retry | null { ... }
// a null in a box — the type lies, and callers write a loop that runs once
function decide(status: number): Retry[] { ... }
// intended
function decide(status: number): Retry { return matched ?? Retry.none(); }Public state should be readonly so callers cannot break an aggregate's
invariants. A declared private/protected field is allowed, and so is any
readonly member.
A decorated property is allowed by default. @Column, @IsString and
friends assign the field from outside the class, so readonly would be a lie
and the property is framework shape rather than state the class chose to carry
— a NestJS DTO or a TypeORM entity is a wall of them. Pass
{ ignoreDecorated: false } to hold them to the same standard.
// passes — the framework populates these
class BankDto {
@IsString() code: string;
@IsOptional() ispb_code?: string;
}
// reported — state the class chose to expose
class Money {
amount = 0;
}Constructor parameter properties are covered at every visibility by
default, which declared fields are not: a private repo: Repository<Proposal>
that nobody marked readonly can still be swapped from inside, and unlike a
declared field it is a collaborator the container handed you. Set
{ parameterProperties: 'public' } to keep the rule to what its name says.
ignoreDecorated deliberately does not reach parameter properties either. A
decorator on a parameter is injection (@Inject(TOKEN)), which supplies a
collaborator rather than populating a field, so it still has no business being
reassignable. max-class-fields draws the same line for the same reason.
This rule asks whether a field is declared changeable. Its behavioural
counterpart is no-self-mutation, which asks whether anything actually
changes it.
A constructor should only wire arguments to fields. Validation, transformation,
and I/O belong in a static factory or a method, keeping object construction
predictable. Parameter properties (constructor(private readonly x: T)) and a
leading super(...) are allowed; computed right-hand sides (this.x = x * 2,
this.items = items.slice()) and any non-assignment statement are flagged.
On a class a DI container builds, the remedy the rule names does not exist:
nobody calls new on a Nest provider, so there is no static factory to move
the work to. The tempting move is to push it into a lifecycle hook, and that
trades one rule for a worse invariant — the field stops being readonly and
starts being assigned some time after construction:
// reported
constructor(private readonly config: ConfigService) {
this.baseUrl = this.config.getOrThrow('COBRANSAAS_BASE_URL');
}
// worse: the field is now mutable and empty until a hook runs
private baseUrl: string;
onModuleInit() {
this.baseUrl = this.config.getOrThrow('COBRANSAAS_BASE_URL');
}Resolve the config where the module is wired, and inject the result. The
constructor goes back to storing an argument, the field stays readonly, and a
missing variable fails at boot instead of on the first request:
// cobransaas.module.ts
providers: [
{
provide: COBRANSAAS_SETTINGS,
inject: [ConfigService],
useFactory: (config: ConfigService): CobransaasSettings => ({
baseUrl: config.getOrThrow('COBRANSAAS_BASE_URL'),
clientId: config.getOrThrow('COBRANSAAS_CLIENT_ID'),
}),
},
]
// cobransaas-http-client.service.ts
constructor(
@Inject(COBRANSAAS_SETTINGS)
private readonly settings: CobransaasSettings,
) {}Getters and setters turn objects into data bags; prefer methods that expose
behavior. Native get/set accessors (and accessor fields) are always
flagged. The opt-in { methods: true } option also flags conventional
getX/setX methods — useful for strict Elegant Objects style, but noisy
around repositories and framework hooks, so it stays off in recommended.
instanceof is type discrimination that belongs inside a polymorphic method on
the object. Pairs with no-type-assertion to keep type-based branching out of
the codebase.
Three uses are allowed by default, because in each of them TypeScript leaves no polymorphic alternative to reach for.
A self-guard — other instanceof Money inside class Money. Value
equality has to guard its own type before comparing fields, and there is no
polymorphic way to write that: the method is already on the object, so the
rule's own advice has nowhere left to go. Guarding against a different type
(other instanceof Currency inside Money) is discrimination and is still
reported. Off via { allowSelfGuard: false }.
A caught value — a name a catch clause introduced. TypeScript types it
as unknown, so instanceof is the only narrowing the language offers; no
method on the value can stand in, because at that point the value has no known
methods. Resolved through the scope chain, so the narrowing still counts one
closure deeper. Off via { allowCaughtValues: false }.
A declared type guard — a function whose return type is a predicate,
value is X. Some classes are nominal and offer no discriminant to switch on:
a framework exception, a value object from another module, an Error subclass.
The check has to happen somewhere, and a value is X signature is the one
place it states what it is doing — the answer leaves as a narrowed type instead
of a bare boolean, the class name is written once, and the project ends up with
one greppable guard per class rather than an instanceof in the middle of a
method. Only the innermost enclosing function counts, so a guard cannot lend
its exemption to the code that follows it. Off via { allowTypeGuards: false }.
This exists so the cheapest way out of the rule is also the honest one. Without
it, the reachable workaround is structural duck typing — 'toDate' in value
instead of value instanceof IsoDate — which passes the linter, passes for any
object that happens to carry the member, and is strictly worse than what it
replaced.
// allowed
class Money {
equals(other?: unknown): boolean {
return other instanceof Money && this.amount === other.amount;
}
}
try { charge(); } catch (error) {
if (error instanceof HttpException) { log(error.getStatus()); }
}
export const isIsoDate = (value: unknown): value is IsoDate =>
value instanceof IsoDate;
// still reported
if (shape instanceof Circle) { draw(); }
function handle(error: HttpException) { return error instanceof HttpException; }
function isIsoDate(value: unknown): boolean { return value instanceof IsoDate; }The last one is the near miss worth spelling out: a function that returns
boolean declares nothing. It is a guard only once the signature says
value is IsoDate.
An error that arrives as a plain parameter rather than through catch — Nest's
ExceptionFilter.catch(exception, host), an RxJS catchError callback — is
not covered, because a parameter's type is whatever the signature says and
the rule reads no type information. Narrow it once at the boundary, or disable
the rule for those files.
Static state and behavior cannot be injected, substituted, or mocked. Prefer
instances (with dependency injection) and a module-level const for shared
values. The { allowReadonly: true } option permits static readonly
constants.
Two kinds of static are allowed by default, because neither is behaviour that anyone would want to substitute.
A secondary constructor — a static whose declared return type is the class
itself. TypeScript cannot overload a constructor, so static of(...): DueDate
inside DueDate is the only way to write one, and calling it is
indistinguishable from calling new. That is what separates a named
constructor from a procedure that moved into a class:
class DueDate {
static of(props: DueDateProps): DueDate {} // allowed
static parse(raw: unknown): DueDate {} // allowed
}
class DocumentFormatter {
static formatCNPJ(document: string): string {} // reported — a module function
}this, Promise<Self> and Self | undefined all count: a polymorphic, an
asynchronous and a failing constructor are still constructors. Self | null
does not, because no-null-return already owns that shape. The return type has
to be written down — the rule carries no type information, so an
unannotated static create() { … } stays reported. On a factory the annotation
is one word, and it is what makes the intent legible. Off via
{ allowSelfReturning: false }.
A Nest module factory — a static returning DynamicModule from a class
decorated with @Module. forRoot, forRootAsync, register and
registerAsync are mandated by the framework, not chosen by the design. Both
halves are required, so naming DynamicModule in a return type is not a way
out of the rule, and a module class gets no blanket exemption for its other
statics. Off via { allowModuleFactories: false }.
Everything else still reports: static accessors (reading one is reaching for
static state, whatever it returns), private static helpers, and static
classes used as a namespace for functions.
Completes no-null-return by banning the null literal as a value everywhere
(const x = null, x === null, fn(null)), pushing absence into explicit types
or undefined. null in type positions (string | null) and a direct
return null (owned by no-null-return) are left alone. This is strict and will
flag idioms like JSON.stringify(x, null, 2) — relax it in the files where you
interoperate with null-based APIs.
A comment inside a body is usually a name that never got written: it labels a run of statements that wanted to be its own function. Move the explanation to a docblock above the function, or extract what it describes and let the call read as the sentence the comment was trying to be.
Applies to every function with a block body — methods, constructors, function declarations, and arrow functions. Comments outside a body are untouched, so docblocks, module-level notes, and comments between class members are fine. Each comment is attributed to the innermost function containing it, so one in a nested arrow is reported once, against that arrow.
Two things are never reported:
- Directives, which the toolchain reads rather than a human:
eslint-disable*,@ts-expect-error,@ts-ignore,prettier-ignore,istanbul ignore,c8 ignore,v8 ignore,webpackChunkName,@vite-ignore. Extend the list with{ allow: string[] }for project conventions such as@codegen. - Comments alone in an empty block, where there is no code to name and the
comment is the only thing explaining the silence. The check looks at the
innermost block, not the function, so an empty
catchkeeps its note even inside a busy function:
function run(): void {
try {
go();
} catch {
// the failure is expected here
}
}When the then branch throws, control never reaches what follows, so else
carries no information and only deepens nesting. Drop it and let the alternative
sit at the outer level, where it reads as the normal path rather than one of two
symmetric cases:
// before
if (amount < 0) {
throw new NegativeAmount(amount);
} else {
process(amount);
}
// after
if (amount < 0) {
throw new NegativeAmount(amount);
}
process(amount);A branch counts as always throwing when it is a bare throw or a block whose
last statement is one. The check does not recurse, which is deliberate: a
block ending in a nested if may or may not throw, and there else still says
something.
else if is flagged too, since the same rewrite applies. The rule has no
options and no autofix — dedenting a block reliably is the formatter's job, not
a linter's.
Its sibling for return is the native
no-else-return, which
recommended enables with allowElseIf: false to match. Prefer the native
rule's defaults? Override it in one line:
'no-else-return': 'error',A log message should be a constant, with everything that varies passed as
structured data. This is not a style preference: `order ${id} confirmed`
produces one distinct message per order, which no aggregator can group, and it
buries id inside prose instead of leaving it as a field you can filter on.
// before — N messages, and the id is not queryable
this.logger.log(`order ${id} confirmed for ${customer}`);
// after — one message, two fields
this.logger.log('order confirmed', { orderId: id, customer });The rule looks only at the message argument, which it takes to be the first
argument that is not an object literal. That lands on the message under either
convention — info(message, data) as in NestJS and winston, info(data, message) as in pino — so the rule never dictates where your data goes. An
interpolated later argument is left alone, since that position is context
rather than the message.
Flagged: template literals with expressions, and + concatenation. A plain
identifier passes, so logger.info(message) is fine — chasing that would need
type information, which no rule in this plugin requires.
A call counts as logging when the method is a level (log, info, warn,
error, debug, verbose, trace, fatal) and the receiver is named logger
or log, whether local or a field (this.logger.info). Both lists are widened
with { objects: string[], methods: string[] }:
'elegant/no-interpolated-log-message': ['error', { objects: ['audit'] }],Known limitation. When the first argument is an identifier, it is taken for the message, so pino's error form slips through:
logger.error(err, `order ${id} failed`); // not reportedTelling that apart from logger.info(message) needs type information. The case
is pinned by a test so the behaviour is deliberate rather than accidental.
A port of Checkstyle's ReturnCount, but not of its threshold. qulice sets it
to 1 — a single exit — which reads well in Java and badly here, because it
outlaws the guard clause that no-else-after-throw in this very preset pushes
you towards. Two rules in one config should not disagree.
At max: 3 the rule stops arguing about single exit and measures sprawl
instead. Two or three guards followed by a final return pass; a function
leaving from six different places is the one worth splitting.
// passes — idiomatic guards
function charge(amount: number): number {
if (amount < 0) return 0;
if (amount > limit) return limit;
return amount;
}Every function gets its own budget, so a callback's exits are never charged to
the function hosting it. Bare return; counts — leaving early is leaving,
value or not. An arrow with an expression body has no return statement at all
and never trips the rule.
Functions are reported by the name that binds them — a declaration's own, a
method's key, or the const or class field holding an arrow — falling back to
(anonymous) for an inline callback. Configurable via { max: number }
(default 3).
A local whose whole job is to hold obj.status is a second name for state the
object already exposes under a name of its own. It buys nothing and it costs a
reader the hop of proving the two are the same value. Ask the object where you
need the answer.
// reported
const objStatus = obj.status;
const authHeader = request.headers.authorization;
const region = this.cognitoRegion;
// passes
obj.status;
request.headers.authorization;
this.cognitoRegion;Only variable declarations are reported. this.total = other.total transfers
state rather than aliasing it, and a property in an object literal
({ id: dto.id }) is how mappers are written; neither trips the rule.
Four shapes are never reported, because in each of them the local is doing real work:
-
A reassigned local.
let status = obj.statusfollowed bystatus = 'EXPIRED'holds mutable state that no member access stands in for. -
A local read inside a nested function. TypeScript drops a narrowing of
obj.propat the callback boundary but keeps it on a local, so inlining such a declaration stops compiling:const status = obj.status; if (status === undefined) return []; return obj.items.map((n) => n + status.length); // needs the local
-
A chain that is not a plain run of
.propaccesses. A computed link (repo.save.mock.calls[1][0].metadata) or a call in the middle (resolveDates(query).startDate) is not a property of an object in hand, and repeating it reads worse than naming it. -
An environment read.
const topicArn = process.env.SNS_ERROR_TOPICfollowed by a guard is fail-fast, and inlining it would read the environment twice. Set{ allowEnv: false }to hold these to the same standard.
Its sibling no-property-destructuring covers the same reach-in written as a
pattern; together they say one thing, which is to ask the object.
This rule is the mirror image of ESLint's native
prefer-destructuring,
which reports const status = obj.status and asks you to write
const { status } = obj instead. The two cannot both be on. prefer-destructuring
is off by default, so there is nothing to undo unless you enabled it — and note
that it only fires when the local and the property share a name, leaving the
renaming majority (const objStatus = obj.status) unreported either way.
const { status, enabled } = obj is no-property-alias written as a pattern:
the object already names its own state, and the locals are a second set of
names for it. This rule covers the pattern form, and only when the thing being
destructured is an object you already hold — a name, this, or a run of plain
.prop accesses rooted at one of those.
// reported
const { status, enabled } = obj;
const { access_token, expires_in } = response.data;
const { region, poolId } = this.config;
// passes — none of these was an object in hand
function create({ id, name }) {}
for (const { id, total } of rows) {}
const { csvContent } = await service.exportCsv(query);
const { startDate } = resolveDates(query);
const [rows, total] = await repo.findAndCount();Parameter patterns, loop bindings, and catch bindings are how you receive a
value rather than reach into one, so they never come up. Neither does
ArrayPattern: const [rows, total] = ... names the halves of a tuple that
carries no names of its own.
Four shapes are never reported, because in each of them the pattern is doing work no member access does:
- A rest element.
const { authorization: _auth, ...safe } = headersconstructs a new object by omission. There is nothing to inline it into. - A default value.
const { max = 3 } = optionsinlines tooptions.max ?? 3, repeating the fallback at every use site. - A local read inside a nested function, for the narrowing reason spelled
out under
no-property-alias. - A local reassigned later.
let { status } = objfollowed bystatus = 'EXPIRED'holds mutable state of its own.
Renaming on the way out (const { ingestion: failure } = row) is still
copying, and so is a single property. Width makes no difference: a pattern
pulling four fields off an input is usually the sign that the method wanted
the object, not the fields.
max-params and no-boolean-param both push you towards an options object —
and an options object typed inline is a bag that got away with it. The
parameter count went down, the coupling did not, and the shape has nowhere to
grow behaviour. Give it a name and it can become a value object; leave it
anonymous and it stays a struct.
// reported
private toResponse(group: { id: string; name: string; members: number }) {}
async createFundingProducts(data: { originCode: string; productId: string }) {}
chart(rows: Array<{ day: string; count: string }>) {}
constructor(private readonly config: { host: string; port: number }) {}
// passes
async register(input: RegisterProposal) {}
function charge(amount: number, currency: string) {}
ingest(raw: Record<string, unknown>) {}A shape counts wherever it hides in the annotation — on its own, in a union
with null, intersected onto a named type, in an array, or inside a generic
argument such as Array<{ … }>. A parameter is reported once however many
shapes it holds, and each offending parameter is reported separately.
Destructuring in the signature (function create({ id }: { id: string }))
does not hide the bag, and neither does a default value.
Inline callbacks are never reported. res.body.items.map((i: { ccbNumber: string; total: number }) => i.total)
annotates whatever the callee yields; when that value has no type to borrow, an
inline shape is the only way to type it at all.
Configurable via { minMembers: number } (default 2). At the default, a
one-property parameter like opts?: { required?: boolean } passes — naming a
single field is usually ceremony rather than design. Set minMembers: 1 to
hold those to the same standard.
Unlike its neighbours, this rule does not have a mechanical fix: it asks you to introduce a named type and decide where it lives. That is a design change, so expect adoption to cost more than a find-and-replace.
no-public-mutable-props asks whether a field is declared changeable. This
asks whether anything actually changes it. A write to this.something after
the constructor has returned means the object is not a value anyone can hold
with confidence: whoever received it a moment ago is now holding something
else.
// reported — each one is a lifecycle, not a value
this.accessToken = access_token;
this.isPolling = false;
this.filterOptionsCache = options;
this.snsClient = new SNSClient({});
// passes — this is where an object is built
constructor(private readonly token: string) {
this.expiresAt = expiry(token);
}Compound assignment (this.count += 1) and increment (this.count++) are
writes too. A computed write (this[key] = value) names no field and is left
alone. Writing to another object (box.value = v) is that object's business.
A callback the constructor schedules is not construction — it runs after
the constructor returned, so setTimeout(() => { this.token = load(); })
inside a constructor is reported.
Nest calls onModuleInit, onApplicationBootstrap, onModuleDestroy,
beforeApplicationShutdown and onApplicationShutdown after the container has
built the instance, finishing a construction the constructor could not — a
timer needs a running event loop. Those five are allowed by default, and the
list is the { allowedMethods: string[] } option; pass [] to hold them to
the same standard, or add your own.
throw new Error('RETRY_BATCH_QUEUE_URL not configured') describes the failure
only in a string the thrower is free to reword. A caller that wants to handle
that case specifically has nothing to catch but Error, which every other
failure also is, so it ends up matching on the message.
// reported
throw new Error('origin codes are required');
throw new TypeError('not a number');
// passes
throw new MissingOriginCodes();
throw new ProposalNotFound(id);
throw error; // rethrow keeps whatever it was
throw invalidRow(raw); // a factory decides which exception to buildCovers the eight built-in error types (Error, TypeError, RangeError,
ReferenceError, SyntaxError, EvalError, URIError, AggregateError).
A subclass is a named exception and passes — that is the whole point. Only
throw is examined: building an Error to hand to Promise.reject or a
callback is a different question and belongs to a different rule.
A port of Checkstyle's MethodLength, which qulice runs. Measured from the
signature to the closing brace, so the declaration and the blank lines that
separate the body's paragraphs count — they are part of what a reader has to
hold.
Named units are measured: methods, function declarations, and a function or
arrow bound to a const. An inline callback is not measured on its own,
because a long callback already makes its host long and the host is what gets
reported. Configurable via { max: number } (default 50), and a warn
rather than an error, like the other thresholds.
max-class-methods, max-class-dependencies, max-class-fields, and the
native max-params all take a max option:
rules: {
...elegant.configs.recommended.rules,
'elegant/max-class-methods': ['warn', { max: 15 }],
'elegant/max-class-dependencies': ['warn', { max: 6 }],
'elegant/max-class-fields': ['warn', { max: 8 }],
'max-params': ['warn', { max: 4 }],
}The recommended config is written for the code you wish you had. Turning it
on over code that already exists is a different exercise, and worth planning
with numbers rather than discovering at the first eslint ..
Measured over 1,261 production TypeScript files across three NestJS services — a DTO-heavy, string-logging, TypeORM-backed shape this preset was built for:
| Rule | Severity | Reports | Per file | In test files |
|---|---|---|---|---|
no-comments-in-function-body |
error |
5,559 | 4.41 | 2,589 |
no-interpolated-log-message |
error |
1,941 | 1.54 | 0 |
no-null |
error |
1,367 | 1.08 | 896 |
no-type-assertion |
error |
524 | 0.42 | 935 |
max-method-lines |
warn |
467 | 0.37 | 1 |
max-params |
warn |
195 | 0.15 | 6 |
no-null-return |
error |
194 | 0.15 | 2 |
no-instanceof |
error |
179 | 0.14 | 0 |
no-generic-error |
error |
163 | 0.13 | 17 |
max-returns |
warn |
125 | 0.10 | 0 |
no-public-mutable-props |
error |
116 | 0.09 | 0 |
no-property-alias |
error |
111 | 0.09 | 1 |
no-logic-in-constructor |
error |
98 | 0.08 | 0 |
no-static-members |
error |
96 | 0.08 | 0 |
no-anonymous-param-type |
error |
91 | 0.07 | 27 |
no-self-mutation |
error |
66 | 0.05 | 0 |
max-class-dependencies |
warn |
64 | 0.05 | 0 |
max-class-fields |
warn |
57 | 0.05 | 0 |
no-property-destructuring |
error |
53 | 0.04 | 0 |
no-boolean-param |
error |
47 | 0.04 | 1 |
max-class-methods |
warn |
40 | 0.03 | 0 |
no-getters-setters |
error |
29 | 0.02 | 0 |
no-else-return |
error |
13 | 0.01 | 0 |
no-else-after-throw |
error |
8 | 0.01 | 0 |
| Total | 11,603 | 9.20 |
Four rules account for 81% of it, and they are the four whose principle has a
boundary this plugin cannot see. no-comments-in-function-body asks you to
rewrite a function, not edit a line. no-interpolated-log-message fires on
whatever logging convention the project already chose, so on a codebase that
logs with template strings it fires everywhere. no-null cannot tell a domain
value from the wire format of a database column. no-type-assertion counts
x as unknown as T twice, once per assertion, which is arguably correct.
None of that makes them wrong — it makes them rules you adopt on purpose
rather than inherit. That is what starter is: every rule recommended
carries, with those four demoted, leaving the 1.75 per file below them — a
list somebody can actually work through.
rules: {
...elegant.configs.starter.rules,
}recommended |
starter |
|
|---|---|---|
no-comments-in-function-body |
error |
off |
no-interpolated-log-message |
error |
warn |
no-null |
error |
warn |
no-type-assertion |
error |
warn |
| everything else | unchanged | unchanged |
Promote them back one at a time as you clear them, and switch to
recommended once nothing is left:
rules: {
...elegant.configs.starter.rules,
'elegant/no-null': 'error', // cleared, so hold the line
}Two rules arrived after that measurement and are not in the table above:
no-any-return, and no-type-assertion's coverage of the non-null operator
x!. Measured separately over a fourth service — 135 production files, same
shape — they are tail rules, not migrations: 2 reports for x! and 0
for no-any-return. The interesting number is from the same repository after
a full pass to green under starter: the tree linted clean, and the two rules
still found one Promise<any> return that had absorbed a cast the pass had
removed. They are cheap to adopt and they close a door the other rules push
people through.
Numbers from one corpus are indicative, not universal. Run
npx eslint . --format json on your own and sort by rule before deciding
anything — the shape of your code decides which of these rules is a signal and
which is a migration.
Spread tests in a config block scoped to your spec globs:
{
files: ['**/*.spec.ts', '**/*.test.ts', '**/*.e2e-spec.ts'],
rules: { ...elegant.configs.tests.rules },
}It turns off eight rules, and the list is a measurement rather than a taste. Over the corpus above, these are the rules that actually report inside test files, each for a reason that holds there and nowhere else:
| Rule | Reports in tests | Why it holds in a spec |
|---|---|---|
no-comments-in-function-body |
2,589 | a spec narrates the scenario |
no-type-assertion |
935 | a mock asserts a type over a partial object |
no-null |
896 | a fixture mirrors a nullable column |
no-anonymous-param-type |
27 | a fixture builder takes an inline shape |
no-generic-error |
17 | throw new Error('boom') as a failure stub |
max-params |
6 | a setup helper |
no-null-return |
2 | a fixture returns absence |
no-boolean-param |
1 | make*(withRefunds: true) names the case under test |
What the list leaves out is deliberate. max-class-fields, max-returns,
no-static-members, no-interpolated-log-message and the other class-shape
rules report zero times in specs on that corpus, so switching them off buys
nothing today and costs you the report on the day a spec finally earns one.
Turn a rule off when you have seen it fire and disagreed — not in advance.
Some files are not written by hand: a migration the TypeORM CLI emits, a script
that generates an OpenAPI document and talks to an operator through console.
Judging them by rules meant for domain code produces churn in files nobody
should reopen. Spread off, which is every rule this plugin ships, disabled:
{
files: ['src/database/migrations/**/*.ts', 'utils/**/*.ts'],
rules: { ...elegant.configs.off.rules },
}Derived from the plugin's own rule list rather than spelled out in your config,
so a rule added in a later version arrives already silent in those files. A
hand-rolled equivalent — mapping over Object.keys(elegant.rules) in your own
config — goes stale the moment it is written.
The package ships a single CommonJS build that is consumable as both
require('@tianjos/eslint-plugin-elegant') and an ESM
import elegant from '@tianjos/eslint-plugin-elegant'. The exported object
exposes { meta, rules, configs }, where configs holds recommended,
starter, tests, and off. All three load paths are exercised against the built output by
tests/dist.test.ts.
This plugin is a TypeScript adaptation of Elegant Objects
(Yegor Bugayenko) and qulice — the Java
quality enforcer that codifies those principles on top of Checkstyle and PMD.
Rules such as no-logic-in-constructor (qulice's ConstructorsCodeFreeCheck),
max-class-dependencies (Checkstyle's ClassDataAbstractionCoupling),
max-class-fields (PMD's TooManyFields),
no-comments-in-function-body (MethodBodyCommentsCheck), no-null,
no-getters-setters, and no-static-members are ports of that
philosophy. The concepts are reimplemented from scratch against the TypeScript
AST; no qulice code is used.
MIT © Thiago