Skip to content

Commit

Permalink
feat(eslint-plugin): add rule prefer-optional-chain (#1213)
Browse files Browse the repository at this point in the history
  • Loading branch information
bradzacher committed Nov 24, 2019
1 parent b16a4b6 commit ad7e1a7
Show file tree
Hide file tree
Showing 7 changed files with 499 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e
| [`@typescript-eslint/prefer-function-type`](./docs/rules/prefer-function-type.md) | Use function types instead of interfaces with call signatures | | :wrench: | |
| [`@typescript-eslint/prefer-includes`](./docs/rules/prefer-includes.md) | Enforce `includes` method over `indexOf` method | :heavy_check_mark: | :wrench: | :thought_balloon: |
| [`@typescript-eslint/prefer-namespace-keyword`](./docs/rules/prefer-namespace-keyword.md) | Require the use of the `namespace` keyword instead of the `module` keyword to declare custom TypeScript modules | :heavy_check_mark: | :wrench: | |
| [`@typescript-eslint/prefer-optional-chain`](./docs/rules/prefer-optional-chain.md) | Prefer using concise optional chain expressions instead of chained logical ands | | :wrench: | |
| [`@typescript-eslint/prefer-readonly`](./docs/rules/prefer-readonly.md) | Requires that private members are marked as `readonly` if they're never modified outside of the constructor | | :wrench: | :thought_balloon: |
| [`@typescript-eslint/prefer-regexp-exec`](./docs/rules/prefer-regexp-exec.md) | Prefer RegExp#exec() over String#match() if no global flag is provided | :heavy_check_mark: | | :thought_balloon: |
| [`@typescript-eslint/prefer-string-starts-ends-with`](./docs/rules/prefer-string-starts-ends-with.md) | Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings | :heavy_check_mark: | :wrench: | :thought_balloon: |
Expand Down
80 changes: 80 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-optional-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Prefer using concise optional chain expressions instead of chained logical ands (prefer-optional-chain)

TypeScript 3.7 added support for the optional chain operator.
This operator allows you to safely access properties and methods on objects when they are potentially `null` or `undefined`.

```ts
type T = {
a?: {
b?: {
c: string;
method?: () => void;
};
};
};

function myFunc(foo: T | null) {
return foo?.a?.b?.c;
}
// is roughly equivalent to
function myFunc(foo: T | null) {
return foo && foo.a && foo.a.b && foo.a.b.c;
}

function myFunc(foo: T | null) {
return foo?.['a']?.b?.c;
}
// is roughly equivalent to
function myFunc(foo: T | null) {
return foo && foo['a'] && foo['a'].b && foo['a'].b.c;
}

function myFunc(foo: T | null) {
return foo?.a?.b?.method?.();
}
// is roughly equivalent to
function myFunc(foo: T | null) {
return foo && foo.a && foo.a.b && foo.a.b.method && foo.a.b.method();
}
```

Because the optional chain operator _only_ chains when the property value is `null` or `undefined`, it is much safer than relying upon logical AND operator chaining `&&`; which chains on any _truthy_ value.

## Rule Details

This rule aims enforce the usage of the safer operator.

Examples of **incorrect** code for this rule:

```ts
foo && foo.a && foo.a.b && foo.a.b.c;
foo && foo['a'] && foo['a'].b && foo['a'].b.c;
foo && foo.a && foo.a.b && foo.a.b.method && foo.a.b.method();

// this rule also supports converting chained strict nullish checks:
foo &&
foo.a != null &&
foo.a.b !== null &&
foo.a.b.c != undefined &&
foo.a.b.c.d !== undefined &&
foo.a.b.c.d.e;
```

Examples of **correct** code for this rule:

```ts
foo?.a?.b?.c;
foo?.['a']?.b?.c;
foo?.a?.b?.method?.();

foo?.a?.b?.c?.d?.e;
```

## When Not To Use It

If you are not using TypeScript 3.7 (or greater), then you will not be able to use this rule, as the operator is not supported.

## Further Reading

- [TypeScript 3.7 Release Notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html)
- [Optional Chaining Proposal](https://github.com/tc39/proposal-optional-chaining/)
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@typescript-eslint/prefer-function-type": "error",
"@typescript-eslint/prefer-includes": "error",
"@typescript-eslint/prefer-namespace-keyword": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/prefer-readonly": "error",
"@typescript-eslint/prefer-regexp-exec": "error",
"@typescript-eslint/prefer-string-starts-ends-with": "error",
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import preferForOf from './prefer-for-of';
import preferFunctionType from './prefer-function-type';
import preferIncludes from './prefer-includes';
import preferNamespaceKeyword from './prefer-namespace-keyword';
import preferOptionalChain from './prefer-optional-chain';
import preferReadonly from './prefer-readonly';
import preferRegexpExec from './prefer-regexp-exec';
import preferStringStartsEndsWith from './prefer-string-starts-ends-with';
Expand Down Expand Up @@ -123,6 +124,7 @@ export default {
'prefer-function-type': preferFunctionType,
'prefer-includes': preferIncludes,
'prefer-namespace-keyword': preferNamespaceKeyword,
'prefer-optional-chain': preferOptionalChain,
'prefer-readonly': preferReadonly,
'prefer-regexp-exec': preferRegexpExec,
'prefer-string-starts-ends-with': preferStringStartsEndsWith,
Expand Down
190 changes: 190 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-optional-chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import {
AST_NODE_TYPES,
TSESTree,
} from '@typescript-eslint/experimental-utils';
import * as util from '../util';

const WHITESPACE_REGEX = /\s/g;

/*
The AST is always constructed such the first element is always the deepest element.
I.e. for this code: `foo && foo.bar && foo.bar.baz && foo.bar.baz.buzz`
The AST will look like this:
{
left: {
left: {
left: foo
right: foo.bar
}
right: foo.bar.baz
}
right: foo.bar.baz.buzz
}
*/
export default util.createRule({
name: 'prefer-optional-chain',
meta: {
type: 'suggestion',
docs: {
description:
'Prefer using concise optional chain expressions instead of chained logical ands',
category: 'Best Practices',
recommended: false,
},
fixable: 'code',
messages: {
preferOptionalChain:
"Prefer using an optional chain expression instead, as it's more concise and easier to read.",
},
schema: [],
},
defaultOptions: [],
create(context) {
const sourceCode = context.getSourceCode();
return {
[[
'LogicalExpression[operator="&&"] > Identifier',
'LogicalExpression[operator="&&"] > BinaryExpression[operator="!=="]',
'LogicalExpression[operator="&&"] > BinaryExpression[operator="!="]',
].join(',')](
initialIdentifierOrNotEqualsExpr:
| TSESTree.BinaryExpression
| TSESTree.Identifier,
): void {
// selector guarantees this cast
const initialExpression = initialIdentifierOrNotEqualsExpr.parent as TSESTree.LogicalExpression;

if (initialExpression.left !== initialIdentifierOrNotEqualsExpr) {
// the identifier is not the deepest left node
return;
}
if (!isValidRightChainTarget(initialExpression.right)) {
// there is nothing to chain with on the right so we can short-circuit the process
return;
}

// walk up the tree to figure out how many logical expressions we can include
let previous: TSESTree.LogicalExpression = initialExpression;
let current: TSESTree.Node = initialExpression;
let previousLeftText = getText(initialIdentifierOrNotEqualsExpr);
let optionallyChainedCode = previousLeftText;
while (current.type === AST_NODE_TYPES.LogicalExpression) {
if (!isValidRightChainTarget(current.right)) {
break;
}

const leftText = previousLeftText;
const rightText = getText(current.right);
if (!rightText.startsWith(leftText)) {
break;
}
// omit weird doubled up expression that make no sense like foo.bar && foo.bar
if (rightText !== leftText) {
previousLeftText = rightText;

/*
Diff the left and right text to construct the fix string
There are the following cases:
1)
rightText === 'foo.bar.baz.buzz'
leftText === 'foo.bar.baz'
diff === '.buzz'
2)
rightText === 'foo.bar.baz.buzz()'
leftText === 'foo.bar.baz'
diff === '.buzz()'
3)
rightText === 'foo.bar.baz.buzz()'
leftText === 'foo.bar.baz.buzz'
diff === '()'
4)
rightText === 'foo.bar.baz[buzz]'
leftText === 'foo.bar.baz'
diff === '[buzz]'
*/
const diff = rightText.replace(leftText, '');
const needsDot = diff.startsWith('(') || diff.startsWith('[');
optionallyChainedCode += `?${needsDot ? '.' : ''}${diff}`;
}

/* istanbul ignore if: this shouldn't ever happen, but types */
if (!current.parent) {
break;
}
previous = current;
current = current.parent;
}

context.report({
node: previous,
messageId: 'preferOptionalChain',
fix(fixer) {
return fixer.replaceText(previous, optionallyChainedCode);
},
});
},
};

function getText(
node:
| TSESTree.BinaryExpression
| TSESTree.CallExpression
| TSESTree.Identifier
| TSESTree.MemberExpression,
): string {
const text = sourceCode.getText(
node.type === AST_NODE_TYPES.BinaryExpression ? node.left : node,
);

// Removes spaces from the source code for the given node
return text.replace(WHITESPACE_REGEX, '');
}
},
});

function isValidRightChainTarget(
node: TSESTree.Node,
): node is
| TSESTree.BinaryExpression
| TSESTree.CallExpression
| TSESTree.MemberExpression {
if (
node.type === AST_NODE_TYPES.MemberExpression ||
node.type === AST_NODE_TYPES.CallExpression
) {
return true;
}

/*
special case for the following, where we only want the left
- foo !== null
- foo != null
- foo !== undefined
- foo != undefined
*/
if (
node.type === AST_NODE_TYPES.BinaryExpression &&
['!==', '!='].includes(node.operator) &&
isValidRightChainTarget(node.left)
) {
if (
node.right.type === AST_NODE_TYPES.Identifier &&
node.right.name === 'undefined'
) {
return true;
}
if (
node.right.type === AST_NODE_TYPES.Literal &&
node.right.value === null
) {
return true;
}
}

return false;
}

0 comments on commit ad7e1a7

Please sign in to comment.