Skip to content

Repository files navigation

@tianjos/eslint-plugin-elegant

npm version license CI

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.

Install

npm install --save-dev @tianjos/eslint-plugin-elegant
pnpm add -D @tianjos/eslint-plugin-elegant
yarn add -D @tianjos/eslint-plugin-elegant

Peer dependencies

This 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/parser

Usage

This 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.

Rules

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:

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

Rule details

no-boolean-param

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.

max-class-methods

A proxy for the Single Responsibility Principle. Constructors are not counted; getters and setters are. Configurable via { max: number } (default 10).

max-class-dependencies

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'] }],

max-class-fields

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.

no-type-assertion

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.

no-any-return

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.

no-null-return

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(); }

no-public-mutable-props

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.

no-logic-in-constructor

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,
) {}

no-getters-setters

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.

no-instanceof

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-guardother 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.

no-static-members

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.

no-null

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.

no-comments-in-function-body

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 catch keeps its note even inside a busy function:
function run(): void {
  try {
    go();
  } catch {
    // the failure is expected here
  }
}

no-else-after-throw

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',

no-interpolated-log-message

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 reported

Telling that apart from logger.info(message) needs type information. The case is pinned by a test so the behaviour is deliberate rather than accidental.

max-returns

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).

no-property-alias

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.status followed by status = 'EXPIRED' holds mutable state that no member access stands in for.

  • A local read inside a nested function. TypeScript drops a narrowing of obj.prop at 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 .prop accesses. 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_TOPIC followed 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.

no-property-destructuring

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 } = headers constructs a new object by omission. There is nothing to inline it into.
  • A default value. const { max = 3 } = options inlines to options.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 } = obj followed by status = '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.

no-anonymous-param-type

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-self-mutation

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.

no-generic-error

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 build

Covers 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.

max-method-lines

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.

Configuration

Overriding 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 }],
}

Adopting on an existing codebase

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.

Relaxing rules in test files

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.

Generated and scaffolded files

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.

Compatibility

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.

Prior art

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.

License

MIT © Thiago

About

Opinionated ESLint rules for elegant, behavior-rich TypeScript

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages