eslint-plugin-no-indexed-access-prop 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valeri Vatchev
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,267 @@
1
+ # eslint-plugin-no-indexed-access-prop
2
+
3
+ ESLint- and Oxlint-compatible rule for forbidding TypeScript indexed access types such as `User['id']` or `T[K]`.
4
+
5
+ The package exports one rule:
6
+
7
+ - `no-indexed-access-prop`
8
+
9
+ ## What it flags
10
+
11
+ Examples reported by default:
12
+
13
+ ```ts
14
+ type UserId = User['id'];
15
+ type Value<T, K extends keyof T> = T[K];
16
+ type UserValue = User['id' | 'name'];
17
+ ```
18
+
19
+ The rule can also be configured more narrowly:
20
+
21
+ - block all indexed access types
22
+ - block only literal property access such as `T['id']`
23
+ - block only selected literal property names such as `['id', 'name']`
24
+
25
+ ## Suggestions
26
+
27
+ The rule provides safe editor suggestions when the replacement can be derived from local syntax alone.
28
+
29
+ Example:
30
+
31
+ ```ts
32
+ type UserId = { id: string }['id'];
33
+ ```
34
+
35
+ Suggested replacement:
36
+
37
+ ```ts
38
+ type UserId = string;
39
+ ```
40
+
41
+ Suggestions are intentionally not emitted for cases that would require cross-file or type-aware resolution, such as:
42
+
43
+ ```ts
44
+ type UserId = User['id'];
45
+ type Value<T, K extends keyof T> = T[K];
46
+ ```
47
+
48
+ ## Installation
49
+
50
+ ### ESLint
51
+
52
+ ```bash
53
+ npm install --save-dev eslint eslint-plugin-no-indexed-access-prop
54
+ ```
55
+
56
+ ### Oxlint
57
+
58
+ ```bash
59
+ npm install --save-dev oxlint eslint-plugin-no-indexed-access-prop
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ### ESLint flat config
65
+
66
+ ```js
67
+ import noIndexedAccessProp from 'eslint-plugin-no-indexed-access-prop';
68
+
69
+ export default [
70
+ {
71
+ files: ['**/*.{ts,tsx}'],
72
+ plugins: {
73
+ noIndexedAccessProp,
74
+ },
75
+ rules: {
76
+ 'noIndexedAccessProp/no-indexed-access-prop': 'error',
77
+ },
78
+ },
79
+ ];
80
+ ```
81
+
82
+ ### Oxlint
83
+
84
+ Using an explicit alias keeps the rule name stable and short:
85
+
86
+ ```json
87
+ {
88
+ "jsPlugins": [
89
+ {
90
+ "name": "no-indexed-access-prop",
91
+ "specifier": "eslint-plugin-no-indexed-access-prop"
92
+ }
93
+ ],
94
+ "rules": {
95
+ "no-indexed-access-prop/no-indexed-access-prop": "error"
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## Options
101
+
102
+ The rule accepts a single options object.
103
+
104
+ ### `mode`
105
+
106
+ Controls which indexed access forms are reported.
107
+
108
+ - `"all"` (default): report every indexed access type
109
+ - `"literal-only"`: report only string-literal access such as `T['id']` or `T['id' | 'name']`
110
+ - `"configured-only"`: report only configured literal property names
111
+
112
+ ### `allowGenericIndex`
113
+
114
+ Only meaningful in `mode: "all"`.
115
+
116
+ When `true`, generic or non-literal indexed access such as `T[K]` is allowed, while literal property access is still reported.
117
+
118
+ ### `allowUnionLiteralIndex`
119
+
120
+ When `true`, union-literal access such as `T['id' | 'name']` is allowed.
121
+
122
+ This exemption applies in every mode.
123
+
124
+ ### `properties`
125
+
126
+ Required when `mode: "configured-only"`.
127
+
128
+ Specifies the blocked literal property names.
129
+
130
+ ## Configuration examples
131
+
132
+ ### Block everything
133
+
134
+ ```json
135
+ {
136
+ "rules": {
137
+ "no-indexed-access-prop/no-indexed-access-prop": "error"
138
+ }
139
+ }
140
+ ```
141
+
142
+ ### Block only literal property access
143
+
144
+ ```json
145
+ {
146
+ "rules": {
147
+ "no-indexed-access-prop/no-indexed-access-prop": [
148
+ "error",
149
+ { "mode": "literal-only" }
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ ### Block only selected properties
156
+
157
+ ```json
158
+ {
159
+ "rules": {
160
+ "no-indexed-access-prop/no-indexed-access-prop": [
161
+ "error",
162
+ { "mode": "configured-only", "properties": ["id", "name"] }
163
+ ]
164
+ }
165
+ }
166
+ ```
167
+
168
+ ### Allow `T[K]` but still block literal property access
169
+
170
+ ```json
171
+ {
172
+ "rules": {
173
+ "no-indexed-access-prop/no-indexed-access-prop": [
174
+ "error",
175
+ { "mode": "all", "allowGenericIndex": true }
176
+ ]
177
+ }
178
+ }
179
+ ```
180
+
181
+ ### Allow union-literal access
182
+
183
+ ```json
184
+ {
185
+ "rules": {
186
+ "no-indexed-access-prop/no-indexed-access-prop": [
187
+ "error",
188
+ { "mode": "literal-only", "allowUnionLiteralIndex": true }
189
+ ]
190
+ }
191
+ }
192
+ ```
193
+
194
+ ## Option migration
195
+
196
+ Older examples may show this shape:
197
+
198
+ ```json
199
+ { "properties": ["id"] }
200
+ ```
201
+
202
+ That shape has been replaced. Use:
203
+
204
+ ```json
205
+ { "mode": "configured-only", "properties": ["id"] }
206
+ ```
207
+
208
+ ## Development
209
+
210
+ ```bash
211
+ npm install
212
+ npm test
213
+ ```
214
+
215
+ ## Publishing notes
216
+
217
+ Before publishing to npm, verify at least the following:
218
+
219
+ 1. `package.json` has the final package name and version
220
+ 2. any desired registry metadata such as `repository`, `bugs`, `homepage`, and `license` is set
221
+ 3. `npm test` passes
222
+ 4. `npm pack --dry-run` contains only the intended artifacts
223
+
224
+ ### First publish from a personal npm account
225
+
226
+ npm trusted publishing cannot be the first publish for a brand new package. The package must already exist on npm before a trusted publisher can be attached to it.
227
+
228
+ This repository includes scripts that isolate the bootstrap publish from your global npm login by using a repo-local user config file, `.npmrc.publish`, via npm's `--userconfig` support.
229
+
230
+ Bootstrap flow:
231
+
232
+ ```bash
233
+ npm run publish:login
234
+ npm run publish:whoami
235
+ npm test
236
+ npm run publish:bootstrap:dry-run
237
+ npm run publish:bootstrap
238
+ ```
239
+
240
+ What this does:
241
+
242
+ - `publish:login` logs into the public npm registry using `./.npmrc.publish` instead of your global `~/.npmrc`
243
+ - `publish:whoami` confirms the active npm identity from that local config
244
+ - `publish:bootstrap` performs the one-time initial publish from your personal account
245
+
246
+ The `.npmrc.publish` file is gitignored and can be deleted after the first publish if you do not want to keep the local credentials around.
247
+
248
+ ### Trusted publishing with GitHub Actions
249
+
250
+ After the first publish succeeds, configure npm trusted publishing for future releases:
251
+
252
+ 1. Open the package settings on npmjs.com
253
+ 2. Open the `Trusted Publisher` section
254
+ 3. Choose `GitHub Actions`
255
+ 4. Configure:
256
+ - Organization or user: `ValTM`
257
+ - Repository: `eslint-plugin-no-indexed-access-prop`
258
+ - Workflow filename: `publish.yml`
259
+
260
+ After that, future releases can be published from GitHub Actions without a long-lived npm token.
261
+
262
+ Trusted publishing references:
263
+
264
+ - https://docs.npmjs.com/trusted-publishers
265
+ - https://docs.npmjs.com/generating-provenance-statements
266
+ - https://docs.npmjs.com/cli/v11/commands/npm-trust
267
+ - https://docs.github.com/en/actions/tutorials/publish-packages/publish-nodejs-packages
@@ -0,0 +1,17 @@
1
+ import { RULE_NAME, noIndexedAccessPropRule } from "./rules/no-indexed-access-prop.js";
2
+ export declare const PACKAGE_NAME = "eslint-plugin-no-indexed-access-prop";
3
+ export declare const PACKAGE_VERSION = "0.1.0";
4
+ export declare const rules: {
5
+ readonly "no-indexed-access-prop": import("@typescript-eslint/utils/ts-eslint").RuleModule<"replaceWithInlinePropertyType" | "replaceWithInlinePropertyTypeUnion" | "unexpectedIndexedAccess" | "unexpectedPropertyIndexedAccess", [import("./rules/no-indexed-access-prop.js").NoIndexedAccessPropOptions], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
6
+ };
7
+ declare const plugin: {
8
+ meta: {
9
+ name: string;
10
+ version: string;
11
+ };
12
+ rules: {
13
+ readonly "no-indexed-access-prop": import("@typescript-eslint/utils/ts-eslint").RuleModule<"replaceWithInlinePropertyType" | "replaceWithInlinePropertyTypeUnion" | "unexpectedIndexedAccess" | "unexpectedPropertyIndexedAccess", [import("./rules/no-indexed-access-prop.js").NoIndexedAccessPropOptions], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener>;
14
+ };
15
+ };
16
+ export { RULE_NAME, noIndexedAccessPropRule };
17
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { RULE_NAME, noIndexedAccessPropRule } from "./rules/no-indexed-access-prop.js";
2
+ export const PACKAGE_NAME = "eslint-plugin-no-indexed-access-prop";
3
+ export const PACKAGE_VERSION = "0.1.0";
4
+ export const rules = {
5
+ [RULE_NAME]: noIndexedAccessPropRule,
6
+ };
7
+ const plugin = {
8
+ meta: {
9
+ name: PACKAGE_NAME,
10
+ version: PACKAGE_VERSION,
11
+ },
12
+ rules,
13
+ };
14
+ export { RULE_NAME, noIndexedAccessPropRule };
15
+ export default plugin;
@@ -0,0 +1,18 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+ export declare const RULE_NAME = "no-indexed-access-prop";
3
+ export type NoIndexedAccessPropOptions = {
4
+ readonly mode?: "all";
5
+ readonly allowGenericIndex?: boolean;
6
+ readonly allowUnionLiteralIndex?: boolean;
7
+ } | {
8
+ readonly mode: "literal-only";
9
+ readonly allowUnionLiteralIndex?: boolean;
10
+ } | {
11
+ readonly mode: "configured-only";
12
+ readonly properties: readonly string[];
13
+ readonly allowUnionLiteralIndex?: boolean;
14
+ };
15
+ type Options = [NoIndexedAccessPropOptions];
16
+ type MessageIds = "replaceWithInlinePropertyType" | "replaceWithInlinePropertyTypeUnion" | "unexpectedIndexedAccess" | "unexpectedPropertyIndexedAccess";
17
+ export declare const noIndexedAccessPropRule: ESLintUtils.RuleModule<MessageIds, Options, unknown, ESLintUtils.RuleListener>;
18
+ export {};
@@ -0,0 +1,220 @@
1
+ import { ESLintUtils } from "@typescript-eslint/utils";
2
+ export const RULE_NAME = "no-indexed-access-prop";
3
+ const createRule = ESLintUtils.RuleCreator.withoutDocs;
4
+ function getMode(options) {
5
+ return options.mode ?? "all";
6
+ }
7
+ function getConfiguredProperties(options) {
8
+ if (options.mode !== "configured-only") {
9
+ return null;
10
+ }
11
+ return new Set(options.properties);
12
+ }
13
+ function getStringLiteralPropertyName(indexType) {
14
+ if (indexType.type !== "TSLiteralType") {
15
+ return null;
16
+ }
17
+ const literal = indexType.literal;
18
+ if (literal.type === "Literal" && typeof literal.value === "string") {
19
+ return literal.value;
20
+ }
21
+ return null;
22
+ }
23
+ function getLiteralIndexAccess(indexType) {
24
+ const propertyName = getStringLiteralPropertyName(indexType);
25
+ if (propertyName != null) {
26
+ return {
27
+ kind: "single-literal",
28
+ propertyNames: [propertyName],
29
+ };
30
+ }
31
+ if (indexType.type !== "TSUnionType") {
32
+ return null;
33
+ }
34
+ const propertyNames = [];
35
+ for (const member of indexType.types) {
36
+ const memberPropertyName = getStringLiteralPropertyName(member);
37
+ if (memberPropertyName == null) {
38
+ return null;
39
+ }
40
+ propertyNames.push(memberPropertyName);
41
+ }
42
+ return {
43
+ kind: "union-literal",
44
+ propertyNames: [...new Set(propertyNames)],
45
+ };
46
+ }
47
+ function getPropertySignatureName(member) {
48
+ if (member.computed || member.optional) {
49
+ return null;
50
+ }
51
+ if (member.key.type === "Identifier") {
52
+ return member.key.name;
53
+ }
54
+ if (member.key.type === "Literal" && typeof member.key.value === "string") {
55
+ return member.key.value;
56
+ }
57
+ return null;
58
+ }
59
+ function getInlinePropertyTypeText(objectType, propertyName, sourceCode) {
60
+ for (const member of objectType.members) {
61
+ if (member.type !== "TSPropertySignature") {
62
+ continue;
63
+ }
64
+ if (getPropertySignatureName(member) !== propertyName) {
65
+ continue;
66
+ }
67
+ if (member.typeAnnotation == null) {
68
+ return null;
69
+ }
70
+ return sourceCode.getText(member.typeAnnotation.typeAnnotation);
71
+ }
72
+ return null;
73
+ }
74
+ function getSuggestion(node, sourceCode) {
75
+ const objectType = node.objectType;
76
+ const literalIndexAccess = getLiteralIndexAccess(node.indexType);
77
+ if (objectType.type !== "TSTypeLiteral" || literalIndexAccess == null) {
78
+ return null;
79
+ }
80
+ const propertyTypeTexts = [];
81
+ for (const propertyName of literalIndexAccess.propertyNames) {
82
+ const propertyTypeText = getInlinePropertyTypeText(objectType, propertyName, sourceCode);
83
+ if (propertyTypeText == null) {
84
+ return null;
85
+ }
86
+ propertyTypeTexts.push(propertyTypeText);
87
+ }
88
+ if (propertyTypeTexts.length === 1) {
89
+ return {
90
+ messageId: "replaceWithInlinePropertyType",
91
+ replacementText: propertyTypeTexts[0],
92
+ };
93
+ }
94
+ return {
95
+ messageId: "replaceWithInlinePropertyTypeUnion",
96
+ replacementText: propertyTypeTexts.map((propertyTypeText) => `(${propertyTypeText})`).join(" | "),
97
+ };
98
+ }
99
+ function getReportDescriptor(options, configuredProperties, literalIndexAccess) {
100
+ if (literalIndexAccess?.kind === "union-literal" && options.allowUnionLiteralIndex) {
101
+ return null;
102
+ }
103
+ switch (getMode(options)) {
104
+ case "literal-only":
105
+ if (literalIndexAccess == null) {
106
+ return null;
107
+ }
108
+ return {
109
+ messageId: "unexpectedIndexedAccess",
110
+ };
111
+ case "configured-only": {
112
+ if (literalIndexAccess == null || configuredProperties == null) {
113
+ return null;
114
+ }
115
+ const matchedProperties = literalIndexAccess.propertyNames.filter((property) => configuredProperties.has(property));
116
+ if (matchedProperties.length === 0) {
117
+ return null;
118
+ }
119
+ return {
120
+ messageId: "unexpectedPropertyIndexedAccess",
121
+ data: {
122
+ properties: matchedProperties.map((property) => `'${property}'`).join(", "),
123
+ },
124
+ };
125
+ }
126
+ case "all":
127
+ if (literalIndexAccess == null && "allowGenericIndex" in options && options.allowGenericIndex) {
128
+ return null;
129
+ }
130
+ return {
131
+ messageId: "unexpectedIndexedAccess",
132
+ };
133
+ }
134
+ return null;
135
+ }
136
+ export const noIndexedAccessPropRule = createRule({
137
+ name: RULE_NAME,
138
+ meta: {
139
+ type: "problem",
140
+ hasSuggestions: true,
141
+ docs: {
142
+ description: "Disallow TypeScript indexed access types using an explicit enforcement mode and optional exemptions.",
143
+ },
144
+ schema: [
145
+ {
146
+ oneOf: [
147
+ {
148
+ type: "object",
149
+ additionalProperties: false,
150
+ properties: {
151
+ mode: { type: "string", enum: ["all"] },
152
+ allowGenericIndex: { type: "boolean" },
153
+ allowUnionLiteralIndex: { type: "boolean" },
154
+ },
155
+ },
156
+ {
157
+ type: "object",
158
+ additionalProperties: false,
159
+ properties: {
160
+ mode: { type: "string", enum: ["literal-only"] },
161
+ allowUnionLiteralIndex: { type: "boolean" },
162
+ },
163
+ required: ["mode"],
164
+ },
165
+ {
166
+ type: "object",
167
+ additionalProperties: false,
168
+ properties: {
169
+ mode: { type: "string", enum: ["configured-only"] },
170
+ properties: {
171
+ type: "array",
172
+ items: {
173
+ type: "string",
174
+ },
175
+ minItems: 1,
176
+ uniqueItems: true,
177
+ },
178
+ allowUnionLiteralIndex: { type: "boolean" },
179
+ },
180
+ required: ["mode", "properties"],
181
+ },
182
+ ],
183
+ },
184
+ ],
185
+ messages: {
186
+ replaceWithInlinePropertyType: "Replace this indexed access with the inline property type.",
187
+ replaceWithInlinePropertyTypeUnion: "Replace this indexed access with the corresponding inline property type union.",
188
+ unexpectedIndexedAccess: "Do not use TypeScript indexed access types. Prefer a named alias or a direct property declaration.",
189
+ unexpectedPropertyIndexedAccess: "Do not use TypeScript indexed access types for blocked properties: {{ properties }}.",
190
+ },
191
+ },
192
+ defaultOptions: [{}],
193
+ create(context, [options]) {
194
+ const configuredProperties = getConfiguredProperties(options);
195
+ return {
196
+ TSIndexedAccessType(node) {
197
+ const literalIndexAccess = getLiteralIndexAccess(node.indexType);
198
+ const suggestion = getSuggestion(node, context.sourceCode);
199
+ const reportDescriptor = getReportDescriptor(options, configuredProperties, literalIndexAccess);
200
+ if (reportDescriptor == null) {
201
+ return;
202
+ }
203
+ context.report({
204
+ node,
205
+ ...reportDescriptor,
206
+ suggest: suggestion == null
207
+ ? undefined
208
+ : [
209
+ {
210
+ messageId: suggestion.messageId,
211
+ fix(fixer) {
212
+ return fixer.replaceText(node, suggestion.replacementText);
213
+ },
214
+ },
215
+ ],
216
+ });
217
+ },
218
+ };
219
+ },
220
+ });
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "eslint-plugin-no-indexed-access-prop",
3
+ "version": "0.1.0",
4
+ "description": "ESLint and Oxlint plugin that forbids TypeScript indexed access property types.",
5
+ "keywords": [
6
+ "eslint",
7
+ "eslint-plugin",
8
+ "oxlint",
9
+ "typescript",
10
+ "indexed-access",
11
+ "typescript-eslint"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/ValTM/eslint-plugin-no-indexed-access-prop.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/ValTM/eslint-plugin-no-indexed-access-prop/issues"
19
+ },
20
+ "homepage": "https://github.com/ValTM/eslint-plugin-no-indexed-access-prop#readme",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ },
31
+ "./rules/no-indexed-access-prop": {
32
+ "types": "./dist/rules/no-indexed-access-prop.d.ts",
33
+ "import": "./dist/rules/no-indexed-access-prop.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.json",
42
+ "prepack": "npm run build",
43
+ "publish:login": "npm login --registry https://registry.npmjs.org --userconfig ./.npmrc.publish",
44
+ "publish:whoami": "npm --registry https://registry.npmjs.org --userconfig ./.npmrc.publish whoami",
45
+ "publish:bootstrap:dry-run": "npm --registry https://registry.npmjs.org --userconfig ./.npmrc.publish publish --dry-run",
46
+ "publish:bootstrap": "npm --registry https://registry.npmjs.org --userconfig ./.npmrc.publish publish",
47
+ "test:unit": "node --test tests/no-indexed-access-prop.test.mjs",
48
+ "test:oxlint": "node --test tests/oxlint-smoke.test.mjs",
49
+ "test": "npm run build && npm run test:unit && npm run test:oxlint"
50
+ },
51
+ "peerDependencies": {
52
+ "eslint": ">=9"
53
+ },
54
+ "devDependencies": {
55
+ "@typescript-eslint/parser": "^8.58.2",
56
+ "@typescript-eslint/rule-tester": "^8.58.2",
57
+ "@typescript-eslint/utils": "^8.58.2",
58
+ "eslint": "^10.2.0",
59
+ "oxlint": "^1.60.0",
60
+ "typescript": "^6.0.2"
61
+ }
62
+ }