es-toolkit-eslint-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/index.d.ts +40 -0
  4. package/dist/index.js +44 -0
  5. package/dist/rules/prefer-clamp.d.ts +3 -0
  6. package/dist/rules/prefer-clamp.js +25 -0
  7. package/dist/rules/prefer-compact.d.ts +3 -0
  8. package/dist/rules/prefer-compact.js +20 -0
  9. package/dist/rules/prefer-delay.d.ts +3 -0
  10. package/dist/rules/prefer-delay.js +34 -0
  11. package/dist/rules/prefer-is-empty.d.ts +3 -0
  12. package/dist/rules/prefer-is-empty.js +24 -0
  13. package/dist/rules/prefer-is-equal.d.ts +3 -0
  14. package/dist/rules/prefer-is-equal.js +22 -0
  15. package/dist/rules/prefer-last.d.ts +3 -0
  16. package/dist/rules/prefer-last.js +21 -0
  17. package/dist/rules/prefer-random-int.d.ts +3 -0
  18. package/dist/rules/prefer-random-int.js +22 -0
  19. package/dist/rules/prefer-range.d.ts +3 -0
  20. package/dist/rules/prefer-range.js +37 -0
  21. package/dist/rules/prefer-sample.d.ts +3 -0
  22. package/dist/rules/prefer-sample.js +21 -0
  23. package/dist/rules/prefer-uniq.d.ts +3 -0
  24. package/dist/rules/prefer-uniq.js +25 -0
  25. package/dist/utils/create-rule.d.ts +4 -0
  26. package/dist/utils/create-rule.js +2 -0
  27. package/docs/rules/prefer-clamp.md +41 -0
  28. package/docs/rules/prefer-compact.md +38 -0
  29. package/docs/rules/prefer-delay.md +42 -0
  30. package/docs/rules/prefer-is-empty.md +40 -0
  31. package/docs/rules/prefer-is-equal.md +41 -0
  32. package/docs/rules/prefer-last.md +41 -0
  33. package/docs/rules/prefer-random-int.md +43 -0
  34. package/docs/rules/prefer-range.md +42 -0
  35. package/docs/rules/prefer-sample.md +39 -0
  36. package/docs/rules/prefer-uniq.md +41 -0
  37. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aliaksandr Zasim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # es-toolkit-eslint-plugin
2
+
3
+ An ESLint plugin that actively suggests [es-toolkit](https://es-toolkit.dev) utilities in
4
+ place of verbose hand-written JS/TS.
5
+
6
+ Rules are **report-only** (no auto-fix) and lean on
7
+ [esquery](https://eslint.org/docs/latest/extend/selectors) selectors to keep detection
8
+ declarative and minimal.
9
+
10
+ > [!WARNING]
11
+ > **Early stage.** This project is at a very early stage and under active development.
12
+ > False positives are possible.
13
+ > Try it out and [file issues](https://github.com/syxov/es-toolkit-eslint-plugin/issues).
14
+
15
+ > Requires **ESLint 10** (flat config).
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ pnpm add -D es-toolkit-eslint-plugin
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```js
26
+ // eslint.config.js
27
+ import esToolkit from 'es-toolkit-eslint-plugin';
28
+
29
+ export default [
30
+ {
31
+ files: ['**/*.{js,ts}'],
32
+ plugins: { 'es-toolkit': esToolkit },
33
+ rules: {
34
+ 'es-toolkit/prefer-clamp': 'error',
35
+ },
36
+ },
37
+ ];
38
+ ```
39
+
40
+ Or extend the bundled config:
41
+
42
+ ```js
43
+ import esToolkit from 'es-toolkit-eslint-plugin';
44
+
45
+ export default [esToolkit.configs.recommended];
46
+ ```
47
+
48
+ ## Rules
49
+
50
+ | Rule | Description |
51
+ | ------------------------------------------------------ | -------------------------------------------------------- |
52
+ | [`prefer-clamp`](docs/rules/prefer-clamp.md) | Prefer `clamp` over nested `Math.min`/`Math.max`. |
53
+ | [`prefer-compact`](docs/rules/prefer-compact.md) | Prefer `compact` over `.filter(Boolean)`. |
54
+ | [`prefer-delay`](docs/rules/prefer-delay.md) | Prefer `delay` over `new Promise` + `setTimeout`. |
55
+ | [`prefer-is-empty`](docs/rules/prefer-is-empty.md) | Prefer `isEmpty` over `Object.keys(obj).length === 0`. |
56
+ | [`prefer-is-equal`](docs/rules/prefer-is-equal.md) | Prefer `isEqual` over comparing `JSON.stringify` output. |
57
+ | [`prefer-last`](docs/rules/prefer-last.md) | Prefer `last` over `arr[arr.length - 1]`. |
58
+ | [`prefer-random-int`](docs/rules/prefer-random-int.md) | Prefer `randomInt` over `Math.floor(Math.random() * …)`. |
59
+ | [`prefer-range`](docs/rules/prefer-range.md) | Prefer `range` over manual index-array construction. |
60
+ | [`prefer-sample`](docs/rules/prefer-sample.md) | Prefer `sample` over random-index array access. |
61
+ | [`prefer-uniq`](docs/rules/prefer-uniq.md) | Prefer `uniq` over `new Set` round-trips. |
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ pnpm install
67
+ pnpm test # vitest + RuleTester
68
+ pnpm build # tsc -> dist/
69
+ ```
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,40 @@
1
+ declare const plugin: {
2
+ meta: {
3
+ name: string;
4
+ version: string;
5
+ };
6
+ rules: {
7
+ 'prefer-clamp': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferClamp", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
8
+ name: string;
9
+ };
10
+ 'prefer-compact': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferCompact", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
11
+ name: string;
12
+ };
13
+ 'prefer-delay': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferDelay", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
14
+ name: string;
15
+ };
16
+ 'prefer-is-empty': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferIsEmpty", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
17
+ name: string;
18
+ };
19
+ 'prefer-is-equal': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferIsEqual", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
20
+ name: string;
21
+ };
22
+ 'prefer-last': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferLast", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
23
+ name: string;
24
+ };
25
+ 'prefer-random-int': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferRandomInt", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
26
+ name: string;
27
+ };
28
+ 'prefer-range': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferRange", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
29
+ name: string;
30
+ };
31
+ 'prefer-sample': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferSample", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
32
+ name: string;
33
+ };
34
+ 'prefer-uniq': import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferUniq", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
35
+ name: string;
36
+ };
37
+ };
38
+ configs: {};
39
+ };
40
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,44 @@
1
+ import { preferClamp } from './rules/prefer-clamp.js';
2
+ import { preferCompact } from './rules/prefer-compact.js';
3
+ import { preferDelay } from './rules/prefer-delay.js';
4
+ import { preferIsEmpty } from './rules/prefer-is-empty.js';
5
+ import { preferIsEqual } from './rules/prefer-is-equal.js';
6
+ import { preferLast } from './rules/prefer-last.js';
7
+ import { preferRandomInt } from './rules/prefer-random-int.js';
8
+ import { preferRange } from './rules/prefer-range.js';
9
+ import { preferSample } from './rules/prefer-sample.js';
10
+ import { preferUniq } from './rules/prefer-uniq.js';
11
+ const plugin = {
12
+ meta: { name: 'es-toolkit-eslint-plugin', version: '1.0.0' },
13
+ rules: {
14
+ 'prefer-clamp': preferClamp,
15
+ 'prefer-compact': preferCompact,
16
+ 'prefer-delay': preferDelay,
17
+ 'prefer-is-empty': preferIsEmpty,
18
+ 'prefer-is-equal': preferIsEqual,
19
+ 'prefer-last': preferLast,
20
+ 'prefer-random-int': preferRandomInt,
21
+ 'prefer-range': preferRange,
22
+ 'prefer-sample': preferSample,
23
+ 'prefer-uniq': preferUniq,
24
+ },
25
+ configs: {},
26
+ };
27
+ plugin.configs = {
28
+ recommended: {
29
+ plugins: { 'es-toolkit': plugin },
30
+ rules: {
31
+ 'es-toolkit/prefer-clamp': 'error',
32
+ 'es-toolkit/prefer-compact': 'error',
33
+ 'es-toolkit/prefer-delay': 'error',
34
+ 'es-toolkit/prefer-is-empty': 'error',
35
+ 'es-toolkit/prefer-is-equal': 'error',
36
+ 'es-toolkit/prefer-last': 'error',
37
+ 'es-toolkit/prefer-random-int': 'error',
38
+ 'es-toolkit/prefer-range': 'error',
39
+ 'es-toolkit/prefer-sample': 'error',
40
+ 'es-toolkit/prefer-uniq': 'error',
41
+ },
42
+ },
43
+ };
44
+ export default plugin;
@@ -0,0 +1,3 @@
1
+ export declare const preferClamp: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferClamp", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,25 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ const mathCall = (method) => `CallExpression[callee.object.name="Math"][callee.property.name="${method}"][arguments.length=2]`;
3
+ // A clamp is an outer Math call wrapping the opposite Math call as a direct argument:
4
+ // Math.min(Math.max(value, lower), upper) or Math.max(Math.min(value, upper), lower).
5
+ const clampPattern = (outer, inner) => `${mathCall(outer)}:has(> ${mathCall(inner)})`;
6
+ export const preferClamp = createRule({
7
+ name: 'prefer-clamp',
8
+ meta: {
9
+ type: 'suggestion',
10
+ docs: {
11
+ description: 'Prefer `clamp` from es-toolkit over nested `Math.min`/`Math.max`.',
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ preferClamp: 'Prefer `clamp` from es-toolkit instead of nesting `Math.min`/`Math.max`.',
16
+ },
17
+ },
18
+ create(context) {
19
+ const report = (node) => context.report({ node, messageId: 'preferClamp' });
20
+ return {
21
+ [clampPattern('min', 'max')]: report,
22
+ [clampPattern('max', 'min')]: report,
23
+ };
24
+ },
25
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferCompact: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferCompact", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,20 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ const filterBoolean = 'CallExpression[callee.property.name="filter"][arguments.length=1][arguments.0.name="Boolean"]';
3
+ export const preferCompact = createRule({
4
+ name: 'prefer-compact',
5
+ meta: {
6
+ type: 'suggestion',
7
+ docs: {
8
+ description: 'Prefer `compact` from es-toolkit over `.filter(Boolean)`.',
9
+ },
10
+ schema: [],
11
+ messages: {
12
+ preferCompact: 'Prefer `compact` from es-toolkit instead of `.filter(Boolean)`.',
13
+ },
14
+ },
15
+ create(context) {
16
+ return {
17
+ [filterBoolean]: (node) => context.report({ node, messageId: 'preferCompact' }),
18
+ };
19
+ },
20
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferDelay: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferDelay", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,34 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ // `new Promise(resolve => setTimeout(resolve, ms))` — a single-param executor whose
3
+ // expression body is a two-argument `setTimeout` whose first argument is an identifier.
4
+ const promisifiedTimeout = 'NewExpression[callee.name="Promise"][arguments.length=1] > ArrowFunctionExpression[params.length=1][params.0.type="Identifier"][body.callee.name="setTimeout"][body.arguments.length=2][body.arguments.0.type="Identifier"]';
5
+ export const preferDelay = createRule({
6
+ name: 'prefer-delay',
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Prefer `delay` from es-toolkit over `new Promise` + `setTimeout`.',
11
+ },
12
+ schema: [],
13
+ messages: {
14
+ preferDelay: 'Prefer `delay` from es-toolkit instead of wrapping `setTimeout` in a `new Promise`.',
15
+ },
16
+ },
17
+ create(context) {
18
+ return {
19
+ [promisifiedTimeout](node) {
20
+ const [param] = node.params;
21
+ const { body } = node;
22
+ // Confirm the timeout callback IS the promise's own `resolve` (a pure delay).
23
+ if (body.type !== 'CallExpression')
24
+ return;
25
+ const [callback] = body.arguments;
26
+ if (param.type === 'Identifier'
27
+ && callback.type === 'Identifier'
28
+ && param.name === callback.name) {
29
+ context.report({ node: node.parent, messageId: 'preferDelay' });
30
+ }
31
+ },
32
+ };
33
+ },
34
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferIsEmpty: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferIsEmpty", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,24 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ const objectKeysLength = (side) => `[${side}.property.name="length"][${side}.object.callee.object.name="Object"][${side}.object.callee.property.name="keys"]`;
3
+ // `Object.keys(obj).length === 0`, in either operand order.
4
+ const keysLengthZero = (zero, keys) => `BinaryExpression[operator=/^===?$/][${zero}.value=0]${objectKeysLength(keys)}`;
5
+ export const preferIsEmpty = createRule({
6
+ name: 'prefer-is-empty',
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Prefer `isEmpty` from es-toolkit over `Object.keys(obj).length === 0`.',
11
+ },
12
+ schema: [],
13
+ messages: {
14
+ preferIsEmpty: 'Prefer `isEmpty` from es-toolkit instead of `Object.keys(obj).length === 0`.',
15
+ },
16
+ },
17
+ create(context) {
18
+ const report = (node) => context.report({ node, messageId: 'preferIsEmpty' });
19
+ return {
20
+ [keysLengthZero('right', 'left')]: report,
21
+ [keysLengthZero('left', 'right')]: report,
22
+ };
23
+ },
24
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferIsEqual: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferIsEqual", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,22 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ const stringify = (side) => `[${side}.callee.object.name="JSON"][${side}.callee.property.name="stringify"]`;
3
+ // `JSON.stringify(a) === JSON.stringify(b)` (or `!==`) is a fragile deep-equality check.
4
+ const stringifyComparison = `BinaryExpression[operator=/^[!=]==?$/]${stringify('left')}${stringify('right')}`;
5
+ export const preferIsEqual = createRule({
6
+ name: 'prefer-is-equal',
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Prefer `isEqual` from es-toolkit over comparing `JSON.stringify` results.',
11
+ },
12
+ schema: [],
13
+ messages: {
14
+ preferIsEqual: 'Prefer `isEqual` from es-toolkit instead of comparing `JSON.stringify` output.',
15
+ },
16
+ },
17
+ create(context) {
18
+ return {
19
+ [stringifyComparison]: (node) => context.report({ node, messageId: 'preferIsEqual' }),
20
+ };
21
+ },
22
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferLast: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferLast", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,21 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ // `arr[arr.length - 1]`: a computed access whose index is `<something>.length - 1`.
3
+ const lastIndex = 'MemberExpression[computed=true][property.operator="-"][property.right.value=1][property.left.property.name="length"]';
4
+ export const preferLast = createRule({
5
+ name: 'prefer-last',
6
+ meta: {
7
+ type: 'suggestion',
8
+ docs: {
9
+ description: 'Prefer `last` from es-toolkit over `arr[arr.length - 1]`.',
10
+ },
11
+ schema: [],
12
+ messages: {
13
+ preferLast: 'Prefer `last` from es-toolkit instead of indexing with `arr.length - 1`.',
14
+ },
15
+ },
16
+ create(context) {
17
+ return {
18
+ [lastIndex]: (node) => context.report({ node, messageId: 'preferLast' }),
19
+ };
20
+ },
21
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferRandomInt: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferRandomInt", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,22 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ const random = 'CallExpression[callee.object.name="Math"][callee.property.name="random"]';
3
+ // `Math.floor(Math.random() * X)`. A `.length` multiplier is left to `prefer-sample`.
4
+ const floorRandom = `CallExpression[callee.object.name="Math"][callee.property.name="floor"][arguments.length=1]:has(> BinaryExpression[operator="*"]:has(> ${random}):not(:has(> MemberExpression[property.name="length"])))`;
5
+ export const preferRandomInt = createRule({
6
+ name: 'prefer-random-int',
7
+ meta: {
8
+ type: 'suggestion',
9
+ docs: {
10
+ description: 'Prefer `randomInt` from es-toolkit over `Math.floor(Math.random() * …)`.',
11
+ },
12
+ schema: [],
13
+ messages: {
14
+ preferRandomInt: 'Prefer `randomInt` from es-toolkit instead of `Math.floor(Math.random() * …)`.',
15
+ },
16
+ },
17
+ create(context) {
18
+ return {
19
+ [floorRandom]: (node) => context.report({ node, messageId: 'preferRandomInt' }),
20
+ };
21
+ },
22
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferRange: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferRange", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,37 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ // `[...Array(n).keys()]` — a spread of the index iterator of a freshly sized array.
3
+ const spreadArrayKeys = 'ArrayExpression[elements.length=1][elements.0.type="SpreadElement"][elements.0.argument.type="CallExpression"][elements.0.argument.callee.property.name="keys"][elements.0.argument.callee.object.callee.name="Array"]';
4
+ // `Array.from({ length: n }, (_, i) => i)` — the mapper returning its index is verified below.
5
+ const arrayFromLength = 'CallExpression[callee.object.name="Array"][callee.property.name="from"][arguments.length=2][arguments.0.type="ObjectExpression"][arguments.0.properties.0.key.name="length"][arguments.1.params.length=2][arguments.1.body.type="Identifier"]';
6
+ export const preferRange = createRule({
7
+ name: 'prefer-range',
8
+ meta: {
9
+ type: 'suggestion',
10
+ docs: {
11
+ description: 'Prefer `range` from es-toolkit over manual index-array construction.',
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ preferRange: 'Prefer `range` from es-toolkit instead of building an index array by hand.',
16
+ },
17
+ },
18
+ create(context) {
19
+ const report = (node) => context.report({ node, messageId: 'preferRange' });
20
+ return {
21
+ [spreadArrayKeys]: report,
22
+ [arrayFromLength](node) {
23
+ const mapper = node.arguments[1];
24
+ if (mapper.type !== 'ArrowFunctionExpression'
25
+ && mapper.type !== 'FunctionExpression')
26
+ return;
27
+ const index = mapper.params[1];
28
+ // Only a mapper that returns its own index parameter yields `range(n)`.
29
+ if (index?.type === 'Identifier'
30
+ && mapper.body.type === 'Identifier'
31
+ && mapper.body.name === index.name) {
32
+ report(node);
33
+ }
34
+ },
35
+ };
36
+ },
37
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferSample: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferSample", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,21 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ // `arr[Math.floor(Math.random() * arr.length)]` — a random index into an array's length.
3
+ const randomIndex = 'MemberExpression[computed=true][property.callee.object.name="Math"][property.callee.property.name="floor"]:has(CallExpression[callee.object.name="Math"][callee.property.name="random"]):has(MemberExpression[property.name="length"])';
4
+ export const preferSample = createRule({
5
+ name: 'prefer-sample',
6
+ meta: {
7
+ type: 'suggestion',
8
+ docs: {
9
+ description: 'Prefer `sample` from es-toolkit over random-index array access.',
10
+ },
11
+ schema: [],
12
+ messages: {
13
+ preferSample: 'Prefer `sample` from es-toolkit instead of indexing with a random index.',
14
+ },
15
+ },
16
+ create(context) {
17
+ return {
18
+ [randomIndex]: (node) => context.report({ node, messageId: 'preferSample' }),
19
+ };
20
+ },
21
+ });
@@ -0,0 +1,3 @@
1
+ export declare const preferUniq: import("@typescript-eslint/utils/ts-eslint").RuleModule<"preferUniq", readonly unknown[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
2
+ name: string;
3
+ };
@@ -0,0 +1,25 @@
1
+ import { createRule } from '../utils/create-rule.js';
2
+ const set = 'NewExpression[callee.name="Set"][arguments.length=1]';
3
+ // Both `[...new Set(arr)]` and `Array.from(new Set(arr))` deduplicate an array.
4
+ const spreadSet = 'ArrayExpression[elements.length=1][elements.0.type="SpreadElement"][elements.0.argument.type="NewExpression"][elements.0.argument.callee.name="Set"][elements.0.argument.arguments.length=1]';
5
+ const arrayFromSet = `CallExpression[callee.object.name="Array"][callee.property.name="from"][arguments.length=1]:has(> ${set})`;
6
+ export const preferUniq = createRule({
7
+ name: 'prefer-uniq',
8
+ meta: {
9
+ type: 'suggestion',
10
+ docs: {
11
+ description: 'Prefer `uniq` from es-toolkit over `new Set` round-trips.',
12
+ },
13
+ schema: [],
14
+ messages: {
15
+ preferUniq: 'Prefer `uniq` from es-toolkit instead of round-tripping through `new Set`.',
16
+ },
17
+ },
18
+ create(context) {
19
+ const report = (node) => context.report({ node, messageId: 'preferUniq' });
20
+ return {
21
+ [spreadSet]: report,
22
+ [arrayFromSet]: report,
23
+ };
24
+ },
25
+ });
@@ -0,0 +1,4 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export declare const createRule: <Options extends readonly unknown[], MessageIds extends string>({ meta, name, ...rule }: Readonly<ESLintUtils.RuleWithMetaAndName<Options, MessageIds, unknown>>) => ESLintUtils.RuleModule<MessageIds, Options, unknown, ESLintUtils.RuleListener> & {
3
+ name: string;
4
+ };
@@ -0,0 +1,2 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ export const createRule = ESLintUtils.RuleCreator(name => `https://github.com/syxov/es-toolkit-eslint-plugin/blob/main/docs/rules/${name}.md`);
@@ -0,0 +1,41 @@
1
+ # prefer-clamp
2
+
3
+ Prefer [`clamp`](https://es-toolkit.dev/reference/math/clamp.html) from es-toolkit over
4
+ nested `Math.min`/`Math.max`.
5
+
6
+ `clamp(value, minimum, maximum)` is exactly `Math.min(Math.max(value, minimum), maximum)`,
7
+ so the nested form is more verbose and harder to read than the named utility.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — any two-bound clamp idiom where a `Math.min`
12
+ call wraps a `Math.max` call (or vice versa) as a direct argument.
13
+
14
+ ### ❌ Incorrect
15
+
16
+ ```js
17
+ Math.min(Math.max(value, lower), upper);
18
+ Math.max(Math.min(value, upper), lower);
19
+ Math.min(upper, Math.max(value, lower)); // argument order does not matter
20
+ ```
21
+
22
+ ### ✅ Correct
23
+
24
+ ```js
25
+ import { clamp } from 'es-toolkit';
26
+
27
+ clamp(value, lower, upper);
28
+
29
+ // Not a clamp — left untouched:
30
+ Math.min(a, b);
31
+ Math.min(Math.min(a, b), c);
32
+ ```
33
+
34
+ ## Limitations
35
+
36
+ - **Syntactic match.** `Math` is matched by name, so a shadowed local variable named
37
+ `Math` could produce a false positive. This is rare in practice.
38
+ - **Two-bound form only.** The single-bound `Math.min(a, b)` / `Math.max(a, b)` form is
39
+ intentionally not reported — it is too common and ambiguous to flag reliably.
40
+ - **No auto-fix by design.** The fix requires adding an `import { clamp } from 'es-toolkit'`
41
+ and choosing argument order, so the rule only reports.
@@ -0,0 +1,38 @@
1
+ # prefer-compact
2
+
3
+ Prefer [`compact`](https://es-toolkit.dev/reference/array/compact.html) from es-toolkit
4
+ over `.filter(Boolean)`.
5
+
6
+ `compact(arr)` removes all falsy values (`false`, `null`, `0`, `''`, `undefined`, `NaN`) —
7
+ exactly what `arr.filter(Boolean)` does, but named and (in TypeScript) better typed.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — a single-argument `.filter(Boolean)` call.
12
+
13
+ ### ❌ Incorrect
14
+
15
+ ```js
16
+ arr.filter(Boolean);
17
+ items.filter(Boolean).map(x => x.id);
18
+ ```
19
+
20
+ ### ✅ Correct
21
+
22
+ ```js
23
+ import { compact } from 'es-toolkit';
24
+
25
+ compact(arr);
26
+
27
+ // Not a falsy filter — left untouched:
28
+ arr.filter(x => x);
29
+ arr.map(Boolean);
30
+ ```
31
+
32
+ ## Limitations
33
+
34
+ - **Syntactic match.** A shadowed local `Boolean` could produce a false positive. This is
35
+ rare in practice.
36
+ - **TypeScript bonus.** Beyond readability, `compact` narrows nullable element types, while
37
+ `filter(Boolean)` often leaves them un-narrowed.
38
+ - **No auto-fix by design.** The fix requires adding an `import { compact } from 'es-toolkit'`.
@@ -0,0 +1,42 @@
1
+ # prefer-delay
2
+
3
+ Prefer [`delay`](https://es-toolkit.dev/reference/promise/delay.html) from es-toolkit over
4
+ wrapping `setTimeout` in a `new Promise`.
5
+
6
+ `delay(ms)` returns a promise that resolves after `ms` milliseconds — the same thing as the
7
+ hand-rolled `new Promise((resolve) => setTimeout(resolve, ms))` sleep idiom.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — a `new Promise` whose single-parameter
12
+ executor immediately calls `setTimeout` with that same parameter as the callback.
13
+
14
+ ### ❌ Incorrect
15
+
16
+ ```js
17
+ new Promise(resolve => setTimeout(resolve, 1000));
18
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```js
24
+ import { delay } from 'es-toolkit';
25
+
26
+ await delay(1000);
27
+
28
+ // Not a pure delay — left untouched:
29
+ new Promise(resolve => setTimeout(doWork, 1000)); // runs work, not just a delay
30
+ new Promise(resolve => {
31
+ setTimeout(resolve, ms); // block-bodied executor, see Limitations
32
+ });
33
+ ```
34
+
35
+ ## Limitations
36
+
37
+ - **Expression-body executor only.** The block-bodied form
38
+ `new Promise((resolve) => { setTimeout(resolve, ms); })` and wrapped forms like
39
+ `setTimeout(() => resolve(), ms)` are not reported, to keep the rule precise.
40
+ - **Identity-checked.** The rule reports only when `setTimeout`'s callback is the promise's
41
+ own `resolve` parameter, so `setTimeout(doWork, ms)` inside a `new Promise` is left alone.
42
+ - **No auto-fix by design.** The fix requires adding an `import { delay } from 'es-toolkit'`.
@@ -0,0 +1,40 @@
1
+ # prefer-is-empty
2
+
3
+ Prefer [`isEmpty`](https://es-toolkit.dev/reference/predicate/isEmpty.html) from es-toolkit
4
+ over `Object.keys(obj).length === 0`.
5
+
6
+ `isEmpty(obj)` answers "does this object have no own enumerable keys?" directly, without
7
+ allocating an intermediate keys array.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — an equality comparison of
12
+ `Object.keys(...).length` against `0`, in either operand order.
13
+
14
+ ### ❌ Incorrect
15
+
16
+ ```js
17
+ Object.keys(obj).length === 0;
18
+ 0 === Object.keys(obj).length;
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```js
24
+ import { isEmpty } from 'es-toolkit';
25
+
26
+ isEmpty(obj);
27
+
28
+ // Not in scope — left untouched:
29
+ Object.keys(obj).length === 1;
30
+ Object.values(obj).length === 0;
31
+ arr.length === 0;
32
+ ```
33
+
34
+ ## Limitations
35
+
36
+ - **`Object.keys` only.** `Object.values(...)`/`Object.entries(...)` length checks and bare
37
+ `arr.length === 0` / `str.length === 0` are intentionally **not** flagged — they are too
38
+ common and clear to rewrite reliably.
39
+ - **Syntactic match.** A shadowed local `Object` could produce a false positive.
40
+ - **No auto-fix by design.** The fix requires adding an `import { isEmpty } from 'es-toolkit'`.
@@ -0,0 +1,41 @@
1
+ # prefer-is-equal
2
+
3
+ Prefer [`isEqual`](https://es-toolkit.dev/reference/predicate/isEqual.html) from es-toolkit
4
+ over comparing `JSON.stringify` output.
5
+
6
+ Comparing `JSON.stringify(a) === JSON.stringify(b)` is a common but fragile deep-equality
7
+ check: it depends on key order, silently drops `undefined`/functions, and throws on circular
8
+ references. `isEqual(a, b)` performs a real structural comparison.
9
+
10
+ ## Rule details
11
+
12
+ This rule reports — it does **not** auto-fix — an equality/inequality comparison whose both
13
+ operands are `JSON.stringify(...)` calls.
14
+
15
+ ### ❌ Incorrect
16
+
17
+ ```js
18
+ JSON.stringify(a) === JSON.stringify(b);
19
+ JSON.stringify(x) !== JSON.stringify(y);
20
+ ```
21
+
22
+ ### ✅ Correct
23
+
24
+ ```js
25
+ import { isEqual } from 'es-toolkit';
26
+
27
+ isEqual(a, b);
28
+
29
+ // Not a stringify-based comparison — left untouched:
30
+ JSON.stringify(a) === b;
31
+ JSON.parse(a) === JSON.parse(b);
32
+ ```
33
+
34
+ ## Limitations
35
+
36
+ - **Both operands must be `JSON.stringify`.** Comparing one stringified value against
37
+ something else is not reported.
38
+ - **Key-order caveat is the point.** `JSON.stringify` equality is order-sensitive and
39
+ lossy — that fragility is exactly why this rule flags it in favor of `isEqual`.
40
+ - **No auto-fix by design.** The fix requires adding an `import { isEqual } from 'es-toolkit'`
41
+ (and inverting with `!` for the `!==` case).
@@ -0,0 +1,41 @@
1
+ # prefer-last
2
+
3
+ Prefer [`last`](https://es-toolkit.dev/reference/array/last.html) from es-toolkit over
4
+ `arr[arr.length - 1]`.
5
+
6
+ `last(arr)` reads as "the last element"; `arr[arr.length - 1]` repeats the array and forces
7
+ the reader to compute the index.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — a computed member access whose index is
12
+ `<something>.length - 1`.
13
+
14
+ ### ❌ Incorrect
15
+
16
+ ```js
17
+ arr[arr.length - 1];
18
+ items[items.length - 1];
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```js
24
+ import { last } from 'es-toolkit';
25
+
26
+ last(arr);
27
+
28
+ // Not a last-element access — left untouched:
29
+ arr[arr.length - 2];
30
+ arr[i - 1];
31
+ arr.at(-1); // the native equivalent, also fine
32
+ ```
33
+
34
+ ## Limitations
35
+
36
+ - **Object identity is not checked.** The selector matches the shape `a[b.length - 1]`, so
37
+ the rare case where the indexed array and the `.length` array differ would also be
38
+ reported.
39
+ - **Native alternative.** `arr.at(-1)` is a concise built-in; this rule suggests es-toolkit's
40
+ `last` to keep call sites consistent with the rest of the toolkit.
41
+ - **No auto-fix by design.** The fix requires adding an `import { last } from 'es-toolkit'`.
@@ -0,0 +1,43 @@
1
+ # prefer-random-int
2
+
3
+ Prefer [`randomInt`](https://es-toolkit.dev/reference/math/randomInt.html) from es-toolkit
4
+ over `Math.floor(Math.random() * …)`.
5
+
6
+ The `Math.floor(Math.random() * n)` family is the classic hand-rolled random-integer
7
+ formula; `randomInt` names the intent and removes the off-by-one foot-guns.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — `Math.floor(...)` applied to a multiplication
12
+ that contains `Math.random()`. A `.length` multiplier is left to
13
+ [`prefer-sample`](./prefer-sample.md) instead.
14
+
15
+ ### ❌ Incorrect
16
+
17
+ ```js
18
+ Math.floor(Math.random() * 6);
19
+ Math.floor(Math.random() * (max - min + 1)) + min;
20
+ ```
21
+
22
+ ### ✅ Correct
23
+
24
+ ```js
25
+ import { randomInt } from 'es-toolkit';
26
+
27
+ randomInt(0, 6);
28
+
29
+ // Not this rule's concern — left untouched:
30
+ Math.random(); // a float in [0, 1)
31
+ Math.round(Math.random() * n); // rounds, not floors
32
+ arr[Math.floor(Math.random() * arr.length)]; // see prefer-sample
33
+ ```
34
+
35
+ ## Limitations
36
+
37
+ - **Bounds differ.** The inclusive idiom `Math.floor(Math.random() * (max - min + 1)) + min`
38
+ yields `[min, max]`, whereas `randomInt(min, max)` yields `[min, max)`. Adjust the upper
39
+ bound when rewriting.
40
+ - **`floor` only.** The `Math.round(...)` variant is not reported (it has a non-uniform
41
+ distribution and is a different intent).
42
+ - **Syntactic match.** A shadowed local `Math` could produce a false positive.
43
+ - **No auto-fix by design.** The fix requires adding an `import { randomInt } from 'es-toolkit'`.
@@ -0,0 +1,42 @@
1
+ # prefer-range
2
+
3
+ Prefer [`range`](https://es-toolkit.dev/reference/math/range.html) from es-toolkit over
4
+ hand-built index arrays.
5
+
6
+ `range(n)` produces `[0, 1, …, n - 1]`. The manual equivalents — `[...Array(n).keys()]` and
7
+ `Array.from({ length: n }, (_, i) => i)` — are noisier ways to say the same thing.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — two index-array idioms:
12
+
13
+ - `[...Array(n).keys()]`
14
+ - `Array.from({ length: n }, (_, i) => i)` (a mapper that returns its own index)
15
+
16
+ ### ❌ Incorrect
17
+
18
+ ```js
19
+ [...Array(n).keys()];
20
+ Array.from({ length: n }, (_, i) => i);
21
+ ```
22
+
23
+ ### ✅ Correct
24
+
25
+ ```js
26
+ import { range } from 'es-toolkit';
27
+
28
+ range(n);
29
+
30
+ // Not a plain index range — left untouched:
31
+ Array.from({ length: n }); // array of holes, not 0…n-1
32
+ Array.from({ length: n }, (_, i) => i * 2); // transformed → range(n).map(...)
33
+ [...arr.keys()]; // keys of an existing array
34
+ ```
35
+
36
+ ## Limitations
37
+
38
+ - **Identity-mapper only.** Only the mapper that returns its index parameter (→ `range(n)`)
39
+ is reported; transformed mappers and `range`'s `start`/`step` variants are not.
40
+ - **Syntactic match.** A shadowed local `Array` could produce a false positive. This is
41
+ rare in practice.
42
+ - **No auto-fix by design.** The fix requires adding an `import { range } from 'es-toolkit'`.
@@ -0,0 +1,39 @@
1
+ # prefer-sample
2
+
3
+ Prefer [`sample`](https://es-toolkit.dev/reference/array/sample.html) from es-toolkit over
4
+ random-index array access.
5
+
6
+ `sample(arr)` returns a random element; `arr[Math.floor(Math.random() * arr.length)]` spells
7
+ out the same idea by hand and repeats the array.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — a computed access whose index is
12
+ `Math.floor(Math.random() * …length)`.
13
+
14
+ ### ❌ Incorrect
15
+
16
+ ```js
17
+ arr[Math.floor(Math.random() * arr.length)];
18
+ items[Math.floor(Math.random() * items.length)];
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```js
24
+ import { sample } from 'es-toolkit';
25
+
26
+ sample(arr);
27
+
28
+ // Not a random element pick — left untouched:
29
+ arr[Math.floor(Math.random() * 3)]; // fixed range, see prefer-random-int
30
+ arr[i];
31
+ ```
32
+
33
+ ## Limitations
34
+
35
+ - **Object identity is not checked.** The selector matches `a[Math.floor(Math.random() *
36
+ b.length)]`, so the rare case where the indexed array and the `.length` array differ would
37
+ also be reported.
38
+ - **Syntactic match.** A shadowed local `Math` could produce a false positive.
39
+ - **No auto-fix by design.** The fix requires adding an `import { sample } from 'es-toolkit'`.
@@ -0,0 +1,41 @@
1
+ # prefer-uniq
2
+
3
+ Prefer [`uniq`](https://es-toolkit.dev/reference/array/uniq.html) from es-toolkit over
4
+ round-tripping an array through `new Set`.
5
+
6
+ `[...new Set(arr)]` and `Array.from(new Set(arr))` exist only to deduplicate; `uniq(arr)`
7
+ states that intent directly.
8
+
9
+ ## Rule details
10
+
11
+ This rule reports — it does **not** auto-fix — an array literal spreading a single
12
+ `new Set(x)`, or `Array.from(new Set(x))` with no mapper.
13
+
14
+ ### ❌ Incorrect
15
+
16
+ ```js
17
+ [...new Set(arr)];
18
+ Array.from(new Set(arr));
19
+ ```
20
+
21
+ ### ✅ Correct
22
+
23
+ ```js
24
+ import { uniq } from 'es-toolkit';
25
+
26
+ uniq(arr);
27
+
28
+ // Not a dedupe round-trip — left untouched:
29
+ new Set(arr);
30
+ [...arr];
31
+ Array.from(new Set(arr), x => x * 2); // a map, not plain uniq
32
+ ```
33
+
34
+ ## Limitations
35
+
36
+ - **Syntactic match.** `Set`/`Array` are matched by name, so shadowed locals could produce
37
+ a false positive. This is rare in practice.
38
+ - **Single-argument `Set` only.** `[...new Set()]` (empty) is not reported.
39
+ - **`Array.from(set, mapFn)`** is intentionally skipped — the second argument transforms
40
+ the result, so it is not a plain `uniq`.
41
+ - **No auto-fix by design.** The fix requires adding an `import { uniq } from 'es-toolkit'`.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "es-toolkit-eslint-plugin",
3
+ "version": "0.1.0",
4
+ "description": "ESLint plugin that suggests es-toolkit utilities in place of verbose JS/TS",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "docs",
17
+ "README.md"
18
+ ],
19
+ "keywords": [
20
+ "eslint",
21
+ "eslintplugin",
22
+ "eslint-plugin",
23
+ "es-toolkit"
24
+ ],
25
+ "license": "MIT",
26
+ "author": "Aliaksandr Zasim",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/syxov/es-toolkit-eslint-plugin.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/syxov/es-toolkit-eslint-plugin/issues"
33
+ },
34
+ "homepage": "https://github.com/syxov/es-toolkit-eslint-plugin#readme",
35
+ "dependencies": {
36
+ "@typescript-eslint/utils": "^8.61.1"
37
+ },
38
+ "peerDependencies": {
39
+ "eslint": ">=10"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "@typescript-eslint/parser": "^8.0.0",
44
+ "@typescript-eslint/rule-tester": "^8.0.0",
45
+ "eslint": "^10.0.0",
46
+ "prettier": "^3.8.4",
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^3.0.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "format": "prettier --write . --experimental-cli --log-level=error",
55
+ "release": "pnpm publish --access public"
56
+ }
57
+ }