biome-typescript-best-practices-plugin 1.0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1 @@
1
+ # biome-typescript-best-practices-plugin
package/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # biome-typescript-best-practices-plugin
2
+
3
+ A [Biome](https://biomejs.dev) plugin (written in [GritQL](https://biomejs.dev/blog/gritql-biome)) that
4
+ enforces TypeScript best practices **not covered by Biome's recommended linter** — catching the patterns
5
+ that quietly bypass the type checker, walk the prototype chain, or sort your numbers wrong.
6
+
7
+ ```ts
8
+ // flagged
9
+ const el = getEl() as HTMLInputElement; // `as` bypasses the type checker
10
+ if ("id" in obj) {} // `in` walks the prototype chain
11
+ items.reduce((acc, x) => acc, {}); // `{}` accumulator leaks prototype keys
12
+ enum Color { Red, Green } // enum emits runtime code
13
+ delete obj[key]; // dynamic delete deoptimises shape
14
+ [3, 20, 100].sort(); // lexicographic → [100, 20, 3]
15
+
16
+ // safe
17
+ const el = getEl(); // narrow with a type guard, or `satisfies`
18
+ if (Object.hasOwn(obj, "id")) {}
19
+ items.reduce((acc, x) => acc.set(x.k, x.v), new Map());
20
+ const Color = { Red: "red", Green: "green" } as const;
21
+ delete obj.prop; // static key is fine
22
+ [3, 20, 100].sort((a, b) => a - b);
23
+ ```
24
+
25
+ ## Rules
26
+
27
+ | Rule | Flags | Why |
28
+ | --- | --- | --- |
29
+ | `ts/no-as-cast` | `expr as T` type assertions (except `as const`) | `as` silences the type checker and can mask real type errors. |
30
+ | `ts/no-in-operator` | the `in` operator and `for...in` | `in` walks the prototype chain, matching inherited/polluted keys. |
31
+ | `ts/no-empty-object-accumulator` | `reduce(…, {})` / `reduceRight(…, {})` | A `{}` accumulator carries `Object.prototype`, so dynamic keys like `"__proto__"` leak in. |
32
+ | `ts/no-enum` | `enum` and `const enum` declarations | Enums emit runtime code with surprising semantics; `const enum` breaks `isolatedModules`. |
33
+ | `ts/no-dynamic-delete` | `delete obj[expr]` with a computed, non-literal key | Deleting a dynamic key deoptimises the object shape and usually means a `Map` was wanted. |
34
+ | `ts/require-array-sort-compare` | `.sort()` / `.toSorted()` with no comparator | Default sort is by UTF-16 code unit, so numbers come out in the wrong order. |
35
+
36
+ All rules report a diagnostic only (severity `warn`, category `plugin`); none apply an auto-fix, because the
37
+ correct repair is context-specific — the plugin flags the hazard and leaves the fix to you.
38
+
39
+ These rules are intentionally **not** duplicates of Biome's recommended set (which already covers
40
+ `noExplicitAny`, `noNonNullAssertion`, `noDoubleEquals`, and similar). They fill gaps that otherwise need
41
+ `typescript-eslint`.
42
+
43
+ ### ts/no-as-cast
44
+
45
+ ```ts
46
+ // flagged
47
+ const el = getEl() as HTMLInputElement;
48
+ const rec = (obj as Record<string, unknown>).key;
49
+ const twice = value as unknown as Target; // double assertion — flagged twice
50
+
51
+ // safe
52
+ const val = input as const; // `as const` narrows a literal, allowed
53
+ const ok = value satisfies Target; // `satisfies` validates without asserting
54
+ ```
55
+
56
+ A type assertion tells the compiler "trust me" and switches off the very check you installed TypeScript
57
+ for. Only `as const` (literal narrowing) is exempt — every other `as T` is flagged. A double assertion
58
+ (`x as unknown as T`) is two `TsAsExpression` nodes, so it reports twice. Inspired by
59
+ [`biome-plugin-no-type-assertion`](https://github.com/albertodeago/biome-plugin-no-type-assertion).
60
+
61
+ ### ts/no-in-operator
62
+
63
+ ```ts
64
+ // flagged
65
+ if ("id" in obj) {}
66
+ for (const k in obj) {}
67
+
68
+ // safe
69
+ if (Object.hasOwn(obj, "id")) {}
70
+ for (const k of Object.keys(obj)) {}
71
+ ```
72
+
73
+ The `in` operator and `for...in` both consult the prototype chain, so inherited or prototype-polluted keys
74
+ match. `Object.hasOwn` is an own-property check; `Object.keys`/`Object.entries` with `for...of` iterate only
75
+ own enumerable keys. Inspired by
76
+ [felixarntz/biome's `no-in-operator`](https://github.com/felixarntz/biome/blob/main/rules/no-in-operator.grit).
77
+
78
+ ### ts/no-empty-object-accumulator
79
+
80
+ ```ts
81
+ // flagged
82
+ items.reduce((acc, x) => { acc[x.k] = x.v; return acc; }, {});
83
+ items.reduceRight((acc, x) => acc, {});
84
+
85
+ // safe
86
+ items.reduce((acc, x) => acc.set(x.k, x.v), new Map());
87
+ items.reduce((acc, x) => acc + x, 0);
88
+ items.reduce((acc, x) => acc, { total: 0 }); // seeded object, not empty
89
+ ```
90
+
91
+ Only an **empty** `{}` seed is flagged — that is the shape used for dynamic-key aggregation, where a key like
92
+ `"__proto__"` or `"constructor"` can collide with `Object.prototype`. A `new Map()` or `Object.create(null)`
93
+ has no such surface. Inspired by
94
+ [felixarntz/biome's `no-empty-object-accumulator`](https://github.com/felixarntz/biome/blob/main/rules/no-empty-object-accumulator.grit).
95
+
96
+ ### ts/no-enum
97
+
98
+ ```ts
99
+ // flagged
100
+ enum Color { Red, Green }
101
+ const enum Dir { Up, Down }
102
+
103
+ // safe
104
+ const Color = { Red: "red", Green: "green" } as const;
105
+ type Color = (typeof Color)[keyof typeof Color];
106
+ ```
107
+
108
+ `enum` is one of the few TypeScript features that emits runtime code, and it has surprising semantics (numeric
109
+ enums are bidirectional maps; a value can be assigned any number). `const enum` is erased but breaks under
110
+ `isolatedModules` / Babel / esbuild. A `const` object with `as const` plus a derived union is fully erasable
111
+ and behaves predictably.
112
+
113
+ ### ts/no-dynamic-delete
114
+
115
+ ```ts
116
+ // flagged
117
+ delete obj[key];
118
+ delete cache[getId()];
119
+ delete registry[user.id];
120
+
121
+ // safe
122
+ delete obj.prop; // static member
123
+ delete obj["literal"]; // literal key
124
+ delete arr[0]; // literal index
125
+ ```
126
+
127
+ Deleting a computed, non-literal key forces the engine to change the object's hidden shape, deoptimising it,
128
+ and usually signals that a `Map` (with `.delete(key)`) was the right structure — or that the field should be
129
+ modelled as optional (`v?: T`). Static members and literal keys are left alone.
130
+
131
+ ### ts/require-array-sort-compare
132
+
133
+ ```ts
134
+ // flagged
135
+ [3, 20, 100].sort(); // → [100, 20, 3]
136
+ numbers.toSorted();
137
+
138
+ // safe
139
+ [3, 20, 100].sort((a, b) => a - b);
140
+ strings.sort(); // NOTE: also flagged — see limitations
141
+ ```
142
+
143
+ `Array#sort` and `Array#toSorted` with no comparator coerce elements to strings and compare by UTF-16 code
144
+ unit, so `[3, 20, 100]` sorts to `[100, 20, 3]`. Passing an explicit comparator makes the order intentional
145
+ and correct.
146
+
147
+ ## Limitations
148
+
149
+ The plugin matches **structure, not types** — it keys off method and operator shapes, not the static type of
150
+ the receiver. Practical consequences:
151
+
152
+ - `ts/require-array-sort-compare` flags every argument-less `.sort()` / `.toSorted()`, including on
153
+ `string[]` where the default order is fine. Add a comparator (`(a, b) => a.localeCompare(b)`) or suppress
154
+ the line if the lexicographic default is intended.
155
+ - `ts/no-empty-object-accumulator` matches any `.reduce(fn, {})` regardless of receiver, and
156
+ `ts/no-dynamic-delete` matches any `delete x[expr]`.
157
+ - Biome's GritQL plugins cannot yet take per-rule configuration, so the matches are intentionally broad. Scope
158
+ the plugin with Biome's `includes` / `overrides` if false positives are a problem, or disable an individual
159
+ rule by editing your copy of the `.grit` file.
160
+
161
+ ## Usage
162
+
163
+ Install the plugin as a dev dependency:
164
+
165
+ ```sh
166
+ npm install -D biome-typescript-best-practices-plugin
167
+ ```
168
+
169
+ Reference it from your Biome configuration:
170
+
171
+ ```jsonc
172
+ {
173
+ "plugins": ["biome-typescript-best-practices-plugin/typescript.grit"],
174
+ "linter": {
175
+ "rules": { "recommended": true }
176
+ }
177
+ }
178
+ ```
179
+
180
+ Then run the linter:
181
+
182
+ ```sh
183
+ npx @biomejs/biome lint <files>
184
+ ```
185
+
186
+ Requires Biome **2.0+** (GritQL plugins landed in v2.0). Developed and tested against Biome 2.5.
187
+
188
+ > Using it directly from this repo instead? Set `"plugins": ["./typescript.grit"]` and point the path at the
189
+ > checked-out file.
190
+
191
+ ## Try it
192
+
193
+ ```sh
194
+ npm install
195
+ npx @biomejs/biome lint example.ts
196
+ ```
197
+
198
+ ## Tests
199
+
200
+ Snapshot tests live in [tests/](tests/). Each case is a pair: `tests/fixtures/<name>.ts` (the source to lint)
201
+ and `<name>.expected.json` (the diagnostics it should produce, as an order-independent array of
202
+ `{ "line": <number>, "rule": "<slug>" }`). The runner ([scripts/run-tests.mjs](scripts/run-tests.mjs)) runs
203
+ `biome lint --reporter=json` on each fixture with only the plugin enabled and compares the extracted
204
+ diagnostics against the expectation.
205
+
206
+ ```sh
207
+ npm test
208
+ ```
209
+
210
+ Every rule has a flagged fixture and a safe counterpart, covering the exempt cases (`as const`,
211
+ `Object.hasOwn`, seeded/`Map` accumulators, `const`-object enums, literal-key deletes, and comparator sorts).
212
+
213
+ ## How it works
214
+
215
+ The plugin is one Biome GritQL file, [typescript.grit](typescript.grit).
216
+
217
+ - `no-as-cast` matches `TsAsExpression(ty = $type)` and excludes `$type <: TsReferenceType(name = \`const\`)`
218
+ so `as const` passes.
219
+ - `no-in-operator` matches `JsInExpression` and `JsForInStatement`.
220
+ - `no-empty-object-accumulator` matches a `reduce`/`reduceRight` call whose second argument is a
221
+ `JsObjectExpression` with an empty member list (`$members <: []`).
222
+ - `no-enum` matches `TsEnumDeclaration` (covers both `enum` and `const enum`).
223
+ - `no-dynamic-delete` matches `delete $target` where `$target` is a `JsComputedMemberExpression` whose key is
224
+ not a string or number literal.
225
+ - `require-array-sort-compare` matches a `sort`/`toSorted` call with an empty argument list (`$args <: []`).
226
+
227
+ ## Releasing
228
+
229
+ Versions and the changelog are managed with [Changesets](https://github.com/changesets/changesets).
230
+
231
+ 1. Add a changeset describing a change: `npx changeset`.
232
+ 2. Commit the changeset to your branch.
233
+ 3. On merge to `main`, the [Release workflow](.github/workflows/release.yml) opens a "Version Packages" pull
234
+ request that bumps the version and updates `CHANGELOG.md`.
235
+ 4. Merge that PR and the workflow publishes the new version to npm.
236
+
237
+ The workflow needs an `NPM_TOKEN` secret in the repo. CI runs the test suite on every push and pull request
238
+ ([.github/workflows/ci.yml](.github/workflows/ci.yml)).
239
+
240
+ ---
241
+
242
+ Inspired by [`biome-plugin-no-type-assertion`](https://github.com/albertodeago/biome-plugin-no-type-assertion)
243
+ and the GritQL rules in [felixarntz/biome](https://github.com/felixarntz/biome).
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "biome-typescript-best-practices-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Biome plugin that enforces TypeScript best practices — no `as` assertions, no `in` operator, no empty-object reduce accumulators, no enums, no dynamic delete, and safe array sorting.",
5
+ "license": "ISC",
6
+ "author": "Ivan Stepanian <iv.stpn@gmail.com>",
7
+ "type": "commonjs",
8
+ "keywords": [
9
+ "biome",
10
+ "biome-plugin",
11
+ "gritql",
12
+ "lint",
13
+ "typescript",
14
+ "best-practices",
15
+ "type-safety"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/iv-stpn/biome-typescript-best-practices-plugin.git"
20
+ },
21
+ "homepage": "https://github.com/iv-stpn/biome-typescript-best-practices-plugin#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/iv-stpn/biome-typescript-best-practices-plugin/issues"
24
+ },
25
+ "files": [
26
+ "typescript.grit",
27
+ "README.md",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "scripts": {
31
+ "test": "node scripts/run-tests.mjs",
32
+ "typecheck": "tsc --noEmit",
33
+ "version": "changeset version",
34
+ "release": "changeset publish"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "2.5.2",
41
+ "@changesets/cli": "2.31.0",
42
+ "@types/node": "^26",
43
+ "typescript": "^6"
44
+ }
45
+ }
@@ -0,0 +1,131 @@
1
+ engine biome(1.0)
2
+ language js
3
+
4
+ // biome-typescript-best-practices-plugin: TypeScript safety & clarity rules.
5
+ //
6
+ // A set of best-practice checks that are NOT part of Biome's default/recommended
7
+ // linter but that push TypeScript towards safer, clearer code. Every rule reports
8
+ // a diagnostic only (no auto-fix): the correct rewrite is context-specific, so the
9
+ // plugin flags the hazard and leaves the fix to the author.
10
+ //
11
+ // Rules:
12
+ // ts/no-as-cast — `expr as T` type assertions (bypasses the checker).
13
+ // ts/no-in-operator — the `in` operator and `for...in` (prototype-chain traps).
14
+ // ts/no-empty-object-accumulator — `reduce(..., {})` with a plain-object accumulator.
15
+ // ts/no-enum — `enum` / `const enum` declarations.
16
+ // ts/no-dynamic-delete — `delete obj[expr]` with a computed, non-literal key.
17
+ // ts/require-array-sort-compare — `.sort()` / `.toSorted()` with no comparator.
18
+ //
19
+ // Inspired by biome-plugin-no-type-assertion and felixarntz/biome's GritQL rules.
20
+
21
+ // A `.sort` / `.toSorted` member name.
22
+ pattern sort_method_name() {
23
+ or { `sort`, `toSorted` }
24
+ }
25
+
26
+ or {
27
+ // Rule: ts/no-as-cast
28
+ // Bad: const el = document.getElementById("x") as HTMLInputElement;
29
+ // Good: use a type guard, a runtime check, or `satisfies` for validation.
30
+ // `as` assertions silence the type checker and can mask real bugs. `as const`
31
+ // is a legitimate literal-narrowing form and is left alone.
32
+ TsAsExpression(ty = $type) as $node where {
33
+ not $type <: TsReferenceType(name = `const`),
34
+ register_diagnostic(
35
+ span = $node,
36
+ message = "[ts/no-as-cast] Avoid `as` type assertions — they bypass the type checker and can hide real type errors. Use a type guard, a runtime check, or `satisfies` to validate a value. (`as const` is allowed.)",
37
+ severity = "warn"
38
+ )
39
+ },
40
+
41
+ // Rule: ts/no-in-operator
42
+ // Bad: if ("id" in obj) { ... } for (const k in obj) { ... }
43
+ // Good: if (Object.hasOwn(obj, "id")) for (const k of Object.keys(obj))
44
+ // `in` walks the prototype chain, so inherited/polluted keys match too, and it
45
+ // gives no type narrowing you can rely on across shapes.
46
+ or {
47
+ JsInExpression() as $node where {
48
+ register_diagnostic(
49
+ span = $node,
50
+ message = "[ts/no-in-operator] Avoid the `in` operator — it walks the prototype chain and matches inherited keys. Use Object.hasOwn(obj, key) for an own-property check.",
51
+ severity = "warn"
52
+ )
53
+ },
54
+ JsForInStatement() as $node where {
55
+ register_diagnostic(
56
+ span = $node,
57
+ message = "[ts/no-in-operator] Avoid `for...in` — it iterates inherited enumerable keys. Use `for (const k of Object.keys(obj))` (or Object.entries) instead.",
58
+ severity = "warn"
59
+ )
60
+ }
61
+ },
62
+
63
+ // Rule: ts/no-empty-object-accumulator
64
+ // Bad: items.reduce((acc, x) => { acc[x.k] = x.v; return acc; }, {})
65
+ // Good: new Map(items.map((x) => [x.k, x.v])) or Object.create(null)
66
+ // A plain `{}` accumulator carries Object.prototype, so dynamic keys like
67
+ // "__proto__" / "constructor" leak into aggregation. A Map (or a null-proto
68
+ // object) has no such surface.
69
+ JsCallExpression(
70
+ callee = JsStaticMemberExpression(member = $method),
71
+ arguments = JsCallArguments(args = $args)
72
+ ) as $call where {
73
+ $method <: or { `reduce`, `reduceRight` },
74
+ $args <: [$_, $init],
75
+ $init <: JsObjectExpression(members = $members),
76
+ $members <: [],
77
+ register_diagnostic(
78
+ span = $call,
79
+ message = "[ts/no-empty-object-accumulator] Avoid a `{}` accumulator in reduce — its prototype chain lets keys like \"__proto__\" leak into dynamic-key aggregation. Use `new Map()` or `Object.create(null)`.",
80
+ severity = "warn"
81
+ )
82
+ },
83
+
84
+ // Rule: ts/no-enum
85
+ // Bad: enum Color { Red, Green } const enum Dir { Up, Down }
86
+ // Good: const Color = { Red: "red", Green: "green" } as const;
87
+ // type Color = (typeof Color)[keyof typeof Color];
88
+ // `enum` emits runtime code, has surprising numeric/string semantics, and
89
+ // `const enum` breaks under isolatedModules. A `const` object + union type is
90
+ // safer and erasable.
91
+ TsEnumDeclaration() as $node where {
92
+ register_diagnostic(
93
+ span = $node,
94
+ message = "[ts/no-enum] Avoid `enum` — it emits runtime code and has surprising semantics (and `const enum` breaks isolatedModules). Prefer a `const` object with `as const` plus a derived union type.",
95
+ severity = "warn"
96
+ )
97
+ },
98
+
99
+ // Rule: ts/no-dynamic-delete
100
+ // Bad: delete obj[key]; delete obj[getKey()];
101
+ // Good: model the field as optional (`v?: T`) or use a Map and `.delete(key)`.
102
+ // Deleting a computed, non-literal key deoptimises the object's shape and often
103
+ // signals that a Map was the right structure. Literal keys are left alone.
104
+ `delete $target` as $node where {
105
+ $target <: JsComputedMemberExpression(member = $key),
106
+ not $key <: or { JsStringLiteralExpression(), JsNumberLiteralExpression() },
107
+ register_diagnostic(
108
+ span = $node,
109
+ message = "[ts/no-dynamic-delete] Avoid `delete` with a computed key — it deoptimises the object shape. Model the property as optional, or use a Map with `.delete(key)`.",
110
+ severity = "warn"
111
+ )
112
+ },
113
+
114
+ // Rule: ts/require-array-sort-compare
115
+ // Bad: [3, 20, 100].sort() // → [100, 20, 3] (lexicographic!)
116
+ // Good: [3, 20, 100].sort((a, b) => a - b)
117
+ // `Array#sort` / `toSorted` with no comparator sorts by UTF-16 code unit, so
118
+ // numbers come out in the wrong order. Always pass an explicit comparator.
119
+ JsCallExpression(
120
+ callee = JsStaticMemberExpression(member = $method),
121
+ arguments = JsCallArguments(args = $args)
122
+ ) as $call where {
123
+ $method <: sort_method_name(),
124
+ $args <: [],
125
+ register_diagnostic(
126
+ span = $call,
127
+ message = "[ts/require-array-sort-compare] `.sort()` / `.toSorted()` with no comparator sorts lexicographically, so numbers sort wrong (e.g. [3,20,100] → [100,20,3]). Pass a compare function, e.g. .sort((a, b) => a - b).",
128
+ severity = "warn"
129
+ )
130
+ }
131
+ }