Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add no-typeof-undefined rule #1966

Merged
merged 14 commits into from Nov 20, 2022
1 change: 1 addition & 0 deletions configs/recommended.js
Expand Up @@ -52,6 +52,7 @@ module.exports = {
'unicorn/no-static-only-class': 'error',
'unicorn/no-thenable': 'error',
'unicorn/no-this-assignment': 'error',
'unicorn/no-typeof-undefined': 'error',
'unicorn/no-unnecessary-await': 'error',
'unicorn/no-unreadable-array-destructuring': 'error',
'unicorn/no-unreadable-iife': 'error',
Expand Down
59 changes: 59 additions & 0 deletions docs/rules/no-typeof-undefined.md
@@ -0,0 +1,59 @@
# Enforce compare with `undefined` directly
fisker marked this conversation as resolved.
Show resolved Hide resolved

✅ This rule is enabled in the `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs).

🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

Enforce compare value with `undefined` directly instead of compare `typeof value` with `'undefined'`.
fisker marked this conversation as resolved.
Show resolved Hide resolved

## Fail

```js
function foo(bar) {
if (typeof bar === 'undefined') {}
}
```

```js
import foo from './foo.js';

if (typeof foo.bar !== 'undefined') {}
```

## Pass

```js
function foo(bar) {
if (foo === undefined) {}
}
```

```js
import foo from './foo.js';

if (foo.bar !== undefined) {}
```

## Options

### checkGlobalVariables

Type: `boolean`\
Default: `false`

This rule ignores variables not defined in file by default.
fisker marked this conversation as resolved.
Show resolved Hide resolved

Set it to `true` to check all variables.

```js
// eslint unicorn/no-typeof-undefined: ["error", {"checkGlobalVariables": true}]
if (typeof undefinedVariable === 'undefined') {} // Fails
```

```js
// eslint unicorn/no-typeof-undefined: ["error", {"checkGlobalVariables": true}]
if (typeof Array === 'undefined') {} // Fails
```
1 change: 1 addition & 0 deletions readme.md
Expand Up @@ -91,6 +91,7 @@ Use a [preset config](#preset-configs) or configure each rules in `package.json`
| [no-static-only-class](docs/rules/no-static-only-class.md) | Disallow classes that only have static members. | ✅ | 🔧 | |
| [no-thenable](docs/rules/no-thenable.md) | Disallow `then` property. | ✅ | | |
| [no-this-assignment](docs/rules/no-this-assignment.md) | Disallow assigning `this` to a variable. | ✅ | | |
| [no-typeof-undefined](docs/rules/no-typeof-undefined.md) | Enforce compare with `undefined` directly. | ✅ | 🔧 | 💡 |
| [no-unnecessary-await](docs/rules/no-unnecessary-await.md) | Disallow awaiting non-promise values. | ✅ | 🔧 | |
| [no-unreadable-array-destructuring](docs/rules/no-unreadable-array-destructuring.md) | Disallow unreadable array destructuring. | ✅ | 🔧 | |
| [no-unreadable-iife](docs/rules/no-unreadable-iife.md) | Disallow unreadable IIFEs. | ✅ | | |
Expand Down
2 changes: 1 addition & 1 deletion rules/no-static-only-class.js
Expand Up @@ -49,7 +49,7 @@ function isStaticMember(node) {
if (
isDeclare
|| isReadonly
|| typeof accessibility !== 'undefined'
|| accessibility !== undefined
|| (Array.isArray(decorators) && decorators.length > 0)
// TODO: Remove this when we drop support for `@typescript-eslint/parser` v4
|| key.type === 'TSPrivateIdentifier'
Expand Down
140 changes: 140 additions & 0 deletions rules/no-typeof-undefined.js
@@ -0,0 +1,140 @@
'use strict';
const isShadowed = require('./utils/is-shadowed.js');
const {matches} = require('./selectors/index.js');
const {
addParenthesizesToReturnOrThrowExpression,
} = require('./fix/index.js');
const {removeSpacesAfter} = require('./fix/index.js');
const isOnSameLine = require('./utils/is-on-same-line.js');
const needsSemicolon = require('./utils/needs-semicolon.js');
const {
isParenthesized,
} = require('./utils/parentheses.js');

const MESSAGE_ID_ERROR = 'no-typeof-undefined/error';
const MESSAGE_ID_SUGGESTION = 'no-typeof-undefined/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'Compare with `undefined` directly instead of `typeof` check.',
fisker marked this conversation as resolved.
Show resolved Hide resolved
[MESSAGE_ID_SUGGESTION]: 'Switch to `… {{operator}} undefined`.',
};

const selector = [
'BinaryExpression',
matches(['===', '!==', '==', '!='].map(operator => `[operator="${operator}"]`)),
'[left.type="UnaryExpression"]',
'[left.operator="typeof"]',
'[left.prefix]',
'[right.type="Literal"]',
].join('');

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const {
checkGlobalVariables,
} = {
checkGlobalVariables: false,
...context.options[0],
};

return {
[selector](binaryExpression) {
const {left: typeofNode, right: undefinedString, operator} = binaryExpression;
if (undefinedString.value !== 'undefined') {
return;
}

const valueNode = typeofNode.argument;
const isGlobalVariable = valueNode.type === 'Identifier'
&& !isShadowed(context.getScope(), valueNode);

if (!checkGlobalVariables && isGlobalVariable) {
return;
}

const sourceCode = context.getSourceCode();
const [typeofToken, secondToken] = sourceCode.getFirstTokens(typeofNode, 2);

const fix = function * (fixer) {
// Change `==`/`!=` to `===`/`!==`
if (operator === '==' || operator === '!=') {
const operatorToken = sourceCode.getTokenAfter(
typeofNode,
token => token.type === 'Punctuator' && token.value === operator,
);

yield fixer.insertTextAfter(operatorToken, '=');
}

yield fixer.replaceText(undefinedString, 'undefined');

yield fixer.remove(typeofToken);
yield removeSpacesAfter(typeofToken, sourceCode, fixer);

const {parent} = binaryExpression;
if (
(parent.type === 'ReturnStatement' || parent.type === 'ThrowStatement')
&& parent.argument === binaryExpression
&& !isOnSameLine(typeofToken, secondToken)
&& !isParenthesized(binaryExpression, sourceCode)
&& !isParenthesized(typeofNode, sourceCode)
) {
yield * addParenthesizesToReturnOrThrowExpression(fixer, parent, sourceCode);
return;
}

const tokenBefore = sourceCode.getTokenBefore(binaryExpression);
if (needsSemicolon(tokenBefore, sourceCode, secondToken.value)) {
yield fixer.insertTextBefore(binaryExpression, ';');
}
};

const problem = {
node: binaryExpression,
loc: typeofToken.loc,
messageId: MESSAGE_ID_ERROR,
};

if (isGlobalVariable) {
problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
data: {operator: operator.startsWith('!') ? '!==' : '==='},
fix,
},
];
} else {
problem.fix = fix;
}

return problem;
},
};
};

const schema = [
{
type: 'object',
additionalProperties: false,
properties: {
checkGlobalVariables: {
type: 'boolean',
default: false,
},
},
},
];

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Enforce compare with `undefined` directly.',
},
fixable: 'code',
hasSuggestions: true,
schema,
messages,
},
};
2 changes: 1 addition & 1 deletion rules/utils/is-same-reference.js
Expand Up @@ -145,7 +145,7 @@ function isSameReference(left, right) {
const nameA = getStaticPropertyName(left);

// `x.y = x["y"]`
if (typeof nameA !== 'undefined') {
if (nameA !== undefined) {
return (
isSameReference(left.object, right.object)
&& nameA === getStaticPropertyName(right)
Expand Down
90 changes: 90 additions & 0 deletions test/no-typeof-undefined.mjs
@@ -0,0 +1,90 @@
import outdent from 'outdent';
import {getTester} from './utils/test.mjs';

const {test} = getTester(import.meta);

test.snapshot({
valid: [
'typeof a.b',
'typeof a.b > "undefined"',
'a.b === "undefined"',
'void a.b === "undefined"',
'+a.b === "undefined"',
'++a.b === "undefined"',
'a.b++ === "undefined"',
'foo === undefined',
'typeof a.b === "string"',
'typeof foo === "undefined"',
'foo = 2; typeof foo === "undefined"',
'/* globals foo: readonly */ typeof foo === "undefined"',
'/* globals globalThis: readonly */ typeof globalThis === "undefined"',
// Cases we are not checking
'"undefined" === typeof a.b',
'const UNDEFINED = "undefined"; typeof a.b === UNDEFINED',
'typeof a.b === `undefined`',
],
invalid: [
'typeof a.b === "undefined"',
'typeof a.b !== "undefined"',
'typeof a.b == "undefined"',
'typeof a.b != "undefined"',
'typeof a.b == \'undefined\'',
'let foo; typeof foo === "undefined"',
'const foo = 1; typeof foo === "undefined"',
'var foo; typeof foo === "undefined"',
'var foo; var foo; typeof foo === "undefined"',
'for (const foo of bar) typeof foo === "undefined";',
outdent`
let foo;
function bar() {
typeof foo === "undefined";
}
`,
'function foo() {typeof foo === "undefined"}',
'function foo(bar) {typeof bar === "undefined"}',
'function foo({bar}) {typeof bar === "undefined"}',
'function foo([bar]) {typeof bar === "undefined"}',
'typeof foo.bar === "undefined"',
outdent`
import foo from 'foo';
typeof foo.bar === "undefined"
`,
// ASI
outdent`
foo
typeof [] === "undefined";
`,
outdent`
foo
typeof (a ? b : c) === "undefined";
`,
outdent`
function a() {
return typeof // comment
a.b === 'undefined';
}
`,
outdent`
function a() {
return (typeof // ReturnStatement argument is parenthesized
a.b === 'undefined');
}
`,
outdent`
function a() {
return (typeof // UnaryExpression is parenthesized
a.b) === 'undefined';
}
`,
],
});

// `checkGlobalVariables: true`
test.snapshot({
valid: [
],
invalid: [
'typeof undefinedVariableIdentifier === "undefined"',
'typeof Array !== "undefined"',
].map(code => ({code, options: [{checkGlobalVariables: true}]})),
});