-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eslint-plugin): add rule prefer-optional-chain (#1213)
- Loading branch information
1 parent
b16a4b6
commit ad7e1a7
Showing
7 changed files
with
499 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
packages/eslint-plugin/docs/rules/prefer-optional-chain.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
190 changes: 190 additions & 0 deletions
190
packages/eslint-plugin/src/rules/prefer-optional-chain.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.