Skip to content
38 changes: 33 additions & 5 deletions packages/eslint-plugin/src/rules/no-for-in-array.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';

import {
createRule,
getConstrainedTypeAtLocation,
getParserServices,
isTypeArrayTypeOrUnionOfArrayTypes,
} from '../util';
import { getForStatementHeadLoc } from '../util/getForStatementHeadLoc';

Expand Down Expand Up @@ -32,10 +32,7 @@ export default createRule({

const type = getConstrainedTypeAtLocation(services, node.right);

if (
isTypeArrayTypeOrUnionOfArrayTypes(type, checker) ||
(type.flags & ts.TypeFlags.StringLike) !== 0
) {
if (isArrayLike(checker, type)) {
context.report({
loc: getForStatementHeadLoc(context.sourceCode, node),
messageId: 'forInViolation',
Expand All @@ -45,3 +42,34 @@ export default createRule({
};
},
});

function isArrayLike(checker: ts.TypeChecker, type: ts.Type): boolean {
return isTypeRecurser(
type,
t => t.getNumberIndexType() != null && hasArrayishLength(checker, t),
);
}

function hasArrayishLength(checker: ts.TypeChecker, type: ts.Type): boolean {
const lengthProperty = type.getProperty('length');

if (lengthProperty == null) {
return false;
}

return tsutils.isTypeFlagSet(
checker.getTypeOfSymbol(lengthProperty),
ts.TypeFlags.NumberLike,
);
}

function isTypeRecurser(
type: ts.Type,
predicate: (t: ts.Type) => boolean,
): boolean {
if (type.isUnionOrIntersection()) {
return type.types.some(t => isTypeRecurser(t, predicate));
}

return predicate(type);
}
6 changes: 6 additions & 0 deletions packages/eslint-plugin/tests/fixtures/tsconfig.lib-dom.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["es2015", "es2017", "esnext", "dom"]
}
}
Comment on lines +1 to +6
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better way to do this? Specifically dom is necessary for the two dom-array-likes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is good!

272 changes: 272 additions & 0 deletions packages/eslint-plugin/tests/rules/no-for-in-array.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ for (const x of [3, 4, 5]) {
`
for (const x in { a: 1, b: 2, c: 3 }) {
console.log(x);
}
`,
// this is normally a type error, this test is here to make sure the rule
// doesn't include an "extra" report for it
`
declare const nullish: null | undefined;
// @ts-expect-error
for (const k in nullish) {
}
`,
`
declare const obj: {
[key: number]: number;
};

for (const key in obj) {
console.log(key);
}
`,
],
Expand Down Expand Up @@ -177,5 +194,260 @@ for (const x
},
],
},
{
code: `
declare const array: string[] | null;

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array: number[] | undefined;

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array: boolean[] | { a: 1; b: 2; c: 3 };

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array: [number, string];

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array: [number, string] | { a: 1; b: 2; c: 3 };

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array: string[] | Record<number, string>;

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
const arrayLike = /fe/.exec('foo');

for (const x in arrayLike) {
console.log(x);
}
`,
errors: [
{
column: 1,
endColumn: 27,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const arrayLike: HTMLCollection;

for (const x in arrayLike) {
console.log(x);
}
`,
errors: [
{
column: 1,
endColumn: 27,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
languageOptions: {
parserOptions: {
project: './tsconfig.lib-dom.json',
projectService: false,
tsconfigRootDir: rootDir,
},
},
},
{
code: `
declare const arrayLike: NodeList;

for (const x in arrayLike) {
console.log(x);
}
`,
errors: [
{
column: 1,
endColumn: 27,
endLine: 4,
line: 4,
messageId: 'forInViolation',
},
],
languageOptions: {
parserOptions: {
project: './tsconfig.lib-dom.json',
projectService: false,
tsconfigRootDir: rootDir,
},
},
},
{
code: `
function foo() {
for (const a in arguments) {
console.log(a);
}
}
`,
errors: [
{
column: 3,
endColumn: 29,
endLine: 3,
line: 3,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array:
| (({ a: string } & string[]) | Record<string, boolean>)
| Record<number, string>;

for (const key in array) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 6,
line: 6,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const array:
| (({ a: string } & RegExpExecArray) | Record<string, boolean>)
| Record<number, string>;

for (const key in array) {
console.log(k);
}
`,
errors: [
{
column: 1,
endColumn: 25,
endLine: 6,
line: 6,
messageId: 'forInViolation',
},
],
},
{
code: `
declare const obj: {
[key: number]: number;
length: 1;
};

for (const key in obj) {
console.log(key);
}
`,
errors: [
{
column: 1,
endColumn: 23,
endLine: 7,
line: 7,
messageId: 'forInViolation',
},
],
},
],
});
Loading