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
feat(eslint-plugin): [prefer-return-this-type] add a new rule #3228
Changes from 6 commits
ee36cf9
d4d5a0a
698a787
59af3dd
b3a5230
8131158
31ef41e
059f7f4
e400889
d19f340
a84ef21
52df4db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
# Enforce that `this` is used when only `this` type is returned (`prefer-return-this-type`) | ||
|
||
[Method chaining](https://en.wikipedia.org/wiki/Method_chaining) is a common pattern in OOP languages and TypeScript provides a special [polymorphic this type](https://www.typescriptlang.org/docs/handbook/2/classes.html#this-types). | ||
If any type other than `this` is specified as the return type of these chaining methods, TypeScript will fail to cast it when invoking in subclass. | ||
|
||
```ts | ||
class Animal { | ||
eat(): Animal { | ||
console.log("I'm moving!"); | ||
return this; | ||
} | ||
} | ||
|
||
class Cat extends Animal { | ||
meow(): Cat { | ||
console.log('Meow~'); | ||
return this; | ||
} | ||
} | ||
|
||
const cat = new Cat(); | ||
// Error: Property 'meow' does not exist on type 'Animal'. | ||
// because `eat` returns `Animal` and not all animals meow. | ||
cat.eat().meow(); | ||
|
||
// the error can be fixed by removing the return type of `eat` or use `this` as the return type. | ||
class Animal { | ||
eat(): this { | ||
console.log("I'm moving!"); | ||
return this; | ||
} | ||
} | ||
|
||
class Cat extends Animal { | ||
meow(): this { | ||
console.log('Meow~'); | ||
return this; | ||
} | ||
} | ||
|
||
const cat = new Cat(); | ||
// no errors. Because `eat` returns `Cat` now | ||
cat.eat().meow(); | ||
``` | ||
|
||
Examples of **incorrect** code for this rule: | ||
|
||
```ts | ||
class Foo { | ||
f1(): Foo { | ||
return this; | ||
} | ||
f2 = (): Foo => { | ||
return this; | ||
}; | ||
} | ||
``` | ||
|
||
Examples of **correct** code for this rule: | ||
|
||
```ts | ||
class Foo { | ||
f1(): this { | ||
return this; | ||
} | ||
f2() { | ||
return this; | ||
} | ||
f3(): Foo | undefined { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👌 |
||
return Math.random() > 0.5 ? this : undefined; | ||
} | ||
f4 = (): this => { | ||
return this; | ||
}; | ||
f5 = () => { | ||
return this; | ||
}; | ||
} | ||
``` | ||
|
||
## When Not To Use It | ||
|
||
If you don't use method chaining or explicit return values, you can safely turn this rule off. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,7 @@ export = { | |
'@typescript-eslint/no-unsafe-member-access': 'error', | ||
'@typescript-eslint/no-unsafe-return': 'error', | ||
'@typescript-eslint/prefer-regexp-exec': 'error', | ||
'@typescript-eslint/prefer-return-this-type': 'error', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you need to regenerate the configs |
||
'require-await': 'off', | ||
'@typescript-eslint/require-await': 'error', | ||
'@typescript-eslint/restrict-plus-operands': 'error', | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,161 @@ | ||||||
import { | ||||||
TSESTree, | ||||||
AST_NODE_TYPES, | ||||||
} from '@typescript-eslint/experimental-utils'; | ||||||
import { createRule, forEachReturnStatement, getParserServices } from '../util'; | ||||||
import * as ts from 'typescript'; | ||||||
|
||||||
const IgnoreTypes = new Set<TSESTree.TypeNode['type']>([ | ||||||
AST_NODE_TYPES.TSThisType, | ||||||
AST_NODE_TYPES.TSAnyKeyword, | ||||||
AST_NODE_TYPES.TSUnknownKeyword, | ||||||
]); | ||||||
|
||||||
type ClassLikeDeclaration = | ||||||
| TSESTree.ClassDeclaration | ||||||
| TSESTree.ClassExpression; | ||||||
|
||||||
type FunctionLike = | ||||||
| TSESTree.MethodDefinition['value'] | ||||||
| TSESTree.ArrowFunctionExpression; | ||||||
|
||||||
export default createRule({ | ||||||
name: 'prefer-return-this-type', | ||||||
defaultOptions: [], | ||||||
|
||||||
meta: { | ||||||
type: 'suggestion', | ||||||
docs: { | ||||||
description: | ||||||
'Enforce that `this` is used when only `this` type is returned', | ||||||
category: 'Best Practices', | ||||||
recommended: 'error', | ||||||
Zzzen marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. adding to the recommended set is a breaking change
Suggested change
|
||||||
requiresTypeChecking: true, | ||||||
}, | ||||||
messages: { | ||||||
UseThisType: 'use `this` type instead.', | ||||||
Zzzen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
}, | ||||||
schema: [], | ||||||
fixable: 'code', | ||||||
}, | ||||||
|
||||||
create(context) { | ||||||
const parserServices = getParserServices(context); | ||||||
const checker = parserServices.program.getTypeChecker(); | ||||||
|
||||||
function isThisSpecifiedInParameters(originalFunc: FunctionLike): boolean { | ||||||
const firstArg = originalFunc.params[0]; | ||||||
return ( | ||||||
firstArg && | ||||||
firstArg.type === AST_NODE_TYPES.Identifier && | ||||||
firstArg.name === 'this' | ||||||
); | ||||||
} | ||||||
|
||||||
function isFunctionAlwaysReturningThis( | ||||||
originalFunc: FunctionLike, | ||||||
originalClass: ClassLikeDeclaration, | ||||||
): boolean { | ||||||
if (isThisSpecifiedInParameters(originalFunc)) { | ||||||
return false; | ||||||
} | ||||||
|
||||||
const func = parserServices.esTreeNodeToTSNodeMap.get(originalFunc); | ||||||
|
||||||
// two things to note here: | ||||||
// 1. arrow function without brackets is flagged as HasImplicitReturn by ts. | ||||||
// 2. ts.NodeFlags.HasImplicitReturn is not accurate. TypeScript compiler uses control flow | ||||||
// analysis to determine if a function has implicit return. | ||||||
const hasImplicitReturn = | ||||||
func.flags & ts.NodeFlags.HasImplicitReturn && | ||||||
!( | ||||||
func.kind === ts.SyntaxKind.ArrowFunction && | ||||||
func.body?.kind !== ts.SyntaxKind.Block | ||||||
); | ||||||
|
||||||
if (hasImplicitReturn || !func.body) { | ||||||
return false; | ||||||
} | ||||||
|
||||||
const classType = checker.getTypeAtLocation( | ||||||
parserServices.esTreeNodeToTSNodeMap.get(originalClass), | ||||||
) as ts.InterfaceType; | ||||||
|
||||||
if (func.body.kind !== ts.SyntaxKind.Block) { | ||||||
const type = checker.getTypeAtLocation(func.body); | ||||||
return classType.thisType === type; | ||||||
} | ||||||
|
||||||
let alwaysReturnsThis = true; | ||||||
let hasReturnStatements = false; | ||||||
|
||||||
forEachReturnStatement(func.body as ts.Block, stmt => { | ||||||
hasReturnStatements = true; | ||||||
|
||||||
const expr = stmt.expression; | ||||||
if (!expr) { | ||||||
alwaysReturnsThis = false; | ||||||
return true; | ||||||
} | ||||||
|
||||||
// fast check | ||||||
if (expr.kind === ts.SyntaxKind.ThisKeyword) { | ||||||
return; | ||||||
} | ||||||
|
||||||
const type = checker.getTypeAtLocation(expr); | ||||||
if (classType.thisType !== type) { | ||||||
alwaysReturnsThis = false; | ||||||
return true; | ||||||
} | ||||||
|
||||||
return undefined; | ||||||
}); | ||||||
|
||||||
return hasReturnStatements && alwaysReturnsThis; | ||||||
} | ||||||
|
||||||
function checkFunction( | ||||||
originalFunc: FunctionLike, | ||||||
originalClass: ClassLikeDeclaration, | ||||||
): void { | ||||||
if ( | ||||||
!originalFunc.returnType || | ||||||
IgnoreTypes.has(originalFunc.returnType.typeAnnotation.type) | ||||||
) { | ||||||
return; | ||||||
} | ||||||
|
||||||
if (isFunctionAlwaysReturningThis(originalFunc, originalClass)) { | ||||||
context.report({ | ||||||
node: originalFunc.returnType, | ||||||
messageId: 'UseThisType', | ||||||
fix(fixer) { | ||||||
return fixer.replaceText( | ||||||
originalFunc.returnType!.typeAnnotation, | ||||||
'this', | ||||||
); | ||||||
}, | ||||||
}); | ||||||
} | ||||||
} | ||||||
|
||||||
return { | ||||||
'ClassBody > MethodDefinition'(node: TSESTree.MethodDefinition): void { | ||||||
checkFunction(node.value, node.parent!.parent as ClassLikeDeclaration); | ||||||
}, | ||||||
'ClassBody > ClassProperty'(node: TSESTree.ClassProperty): void { | ||||||
if ( | ||||||
!( | ||||||
node.value?.type === AST_NODE_TYPES.FunctionExpression || | ||||||
node.value?.type === AST_NODE_TYPES.ArrowFunctionExpression | ||||||
) | ||||||
) { | ||||||
return; | ||||||
} | ||||||
|
||||||
checkFunction(node.value, node.parent!.parent as ClassLikeDeclaration); | ||||||
}, | ||||||
Comment on lines
+161
to
+175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. one easier way to do this is to use a stack to track your state. interface Stack { classLike: ClassLikeDeclaration, methodLike: FunctionLike | null }
const stack: Stack[] = [];
function getCurrentStack(): Stack | undefined {
return stack[stack.length - 1];
}
return {
'ClassDeclaration, ClassExpression:enter'(node: ClassLikeDeclaration) {
stack.push({ classLike: node, methodLike: null });
},
'ClassDeclaration, ClassExpression:exit'(node: ClassLikeDeclaration) {
stack.pop();
},
'ClassBody > MethodDefinition:enter, ClassBody > ClassProperty > :matches(FunctionExpression, ArrowFunctionExpression).value:enter'(node: FunctionLike ) {
const current = getCurrentStack();
if (current != null) {
current.methodLike = node;
}
},
'ClassBody > MethodDefinition, ClassBody > ClassProperty > :matches(FunctionExpression, ArrowFunctionExpression).value:exit'(node: FunctionLike ) {
const current = getCurrentStack();
if (current != null) {
current.methodLike = null;
}
},
ReturnStatement(node) {
const current = getCurrentStack();
if (current != null && current.methodLike != null) {
// check return and report
}
},
}; There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, I haven't seen this pattern before, will try it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is more complicated than I have expected. We may have functions inside methods and need to track them too. class Foo {
bar() {
const f = function() {
return this;
}
}
} |
||||||
}; | ||||||
}, | ||||||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you need to regen the docs to remove the recommended tick