diff --git a/packages/eslint-plugin/src/rules/prefer-readonly.ts b/packages/eslint-plugin/src/rules/prefer-readonly.ts index a393888926cf..ce3f75ebc22d 100644 --- a/packages/eslint-plugin/src/rules/prefer-readonly.ts +++ b/packages/eslint-plugin/src/rules/prefer-readonly.ts @@ -173,6 +173,38 @@ export default createRule({ }; } + function getTypeAnnotationForViolatingNode( + node: TSESTree.Node, + type: ts.Type, + initializerType: ts.Type, + ) { + const annotation = checker.typeToString(type); + + // verify the about-to-be-added type annotation is in-scope + if (tsutils.isTypeFlagSet(initializerType, ts.TypeFlags.EnumLiteral)) { + const scope = context.sourceCode.getScope(node); + const variable = ASTUtils.findVariable(scope, annotation); + + if (variable == null) { + return null; + } + + const definition = variable.defs.find(def => def.isTypeDefinition); + + if (definition == null) { + return null; + } + + const definitionType = services.getTypeAtLocation(definition.node); + + if (definitionType !== type) { + return null; + } + } + + return annotation; + } + return { [`${functionScopeBoundaries}:exit`]( node: @@ -229,13 +261,62 @@ export default createRule({ } })(); + const typeAnnotation = (() => { + if (esNode.type !== AST_NODE_TYPES.PropertyDefinition) { + return null; + } + + if (esNode.typeAnnotation || !esNode.value) { + return null; + } + + if (nameNode.type !== AST_NODE_TYPES.Identifier) { + return null; + } + + const hasConstructorModifications = + finalizedClassScope.memberHasConstructorModifications( + nameNode.name, + ); + + if (!hasConstructorModifications) { + return null; + } + + const violatingType = services.getTypeAtLocation(esNode); + const initializerType = services.getTypeAtLocation(esNode.value); + + // if the RHS is a literal, its type would be narrowed, while the + // type of the initializer (which isn't `readonly`) would be the + // widened type + if (initializerType === violatingType) { + return null; + } + + if (!tsutils.isLiteralType(initializerType)) { + return null; + } + + return getTypeAnnotationForViolatingNode( + esNode, + violatingType, + initializerType, + ); + })(); + context.report({ ...reportNodeOrLoc, messageId: 'preferReadonly', data: { name: context.sourceCode.getText(nameNode), }, - fix: fixer => fixer.insertTextBefore(nameNode, 'readonly '), + *fix(fixer) { + yield fixer.insertTextBefore(nameNode, 'readonly '); + + if (typeAnnotation) { + yield fixer.insertTextAfter(nameNode, `: ${typeAnnotation}`); + } + }, }); } }, @@ -288,6 +369,8 @@ class ClassScope { private readonly classType: ts.Type; private constructorScopeDepth = OUTSIDE_CONSTRUCTOR; private readonly memberVariableModifications = new Set(); + private readonly memberVariableWithConstructorModifications = + new Set(); private readonly privateModifiableMembers = new Map< string, ParameterOrPropertyDeclaration @@ -358,6 +441,7 @@ class ClassScope { relationOfModifierTypeToClass === TypeToClassRelation.Instance && this.constructorScopeDepth === DIRECTLY_INSIDE_CONSTRUCTOR ) { + this.memberVariableWithConstructorModifications.add(node.name.text); return; } @@ -465,4 +549,8 @@ class ClassScope { return TypeToClassRelation.Instance; } + + public memberHasConstructorModifications(name: string) { + return this.memberVariableWithConstructorModifications.has(name); + } } diff --git a/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts b/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts index 1f0d7383c1db..1e2289d65d1e 100644 --- a/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts @@ -956,7 +956,7 @@ class Foo { ], output: ` class TestIncorrectlyModifiableDelayed { - private readonly incorrectlyModifiableDelayed = 7; + private readonly incorrectlyModifiableDelayed: number = 7; public constructor() { this.incorrectlyModifiableDelayed = 7; @@ -2333,5 +2333,1113 @@ function ClassWithName {}>(Base: TBase) { } `, }, + { + code: ` + class Test { + private prop = 'hello'; + + constructor() { + this.prop = 'world'; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop: string = 'hello'; + + constructor() { + this.prop = 'world'; + } + } + `, + }, + { + code: ` + class Test { + private prop = 'hello'; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = 'hello'; + } + `, + }, + { + code: ` + declare const hello: 'hello'; + + class Test { + private prop = hello; + + constructor() { + this.prop = 'world'; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 5, + line: 5, + messageId: 'preferReadonly', + }, + ], + output: ` + declare const hello: 'hello'; + + class Test { + private readonly prop = hello; + + constructor() { + this.prop = 'world'; + } + } + `, + }, + { + code: ` + declare const hello: 'hello'; + + class Test { + private prop = hello; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 5, + line: 5, + messageId: 'preferReadonly', + }, + ], + output: ` + declare const hello: 'hello'; + + class Test { + private readonly prop = hello; + } + `, + }, + { + code: ` + class Test { + private prop = 10; + + constructor() { + this.prop = 11; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop: number = 10; + + constructor() { + this.prop = 11; + } + } + `, + }, + { + code: ` + class Test { + private prop = 10; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = 10; + } + `, + }, + { + code: ` + declare const hello: 10; + + class Test { + private prop = hello; + + constructor() { + this.prop = 11; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 5, + line: 5, + messageId: 'preferReadonly', + }, + ], + output: ` + declare const hello: 10; + + class Test { + private readonly prop = hello; + + constructor() { + this.prop = 11; + } + } + `, + }, + { + code: ` + class Test { + private prop = true; + + constructor() { + this.prop = false; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop: boolean = true; + + constructor() { + this.prop = false; + } + } + `, + }, + { + code: ` + class Test { + private prop = true; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = true; + } + `, + }, + { + code: ` + declare const hello: true; + + class Test { + private prop = hello; + + constructor() { + this.prop = false; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 5, + line: 5, + messageId: 'preferReadonly', + }, + ], + output: ` + declare const hello: true; + + class Test { + private readonly prop = hello; + + constructor() { + this.prop = false; + } + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + class Test { + private prop = Foo.Bar; + + constructor() { + this.prop = Foo.Bazz; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 8, + line: 8, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + class Test { + private readonly prop: Foo = Foo.Bar; + + constructor() { + this.prop = Foo.Bazz; + } + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + class Test { + private prop = Foo.Bar; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 8, + line: 8, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + class Test { + private readonly prop = Foo.Bar; + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + const foo = Foo.Bar; + + class Test { + private prop = foo; + + constructor() { + this.prop = foo; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 10, + line: 10, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + const foo = Foo.Bar; + + class Test { + private readonly prop: Foo = foo; + + constructor() { + this.prop = foo; + } + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + const foo = Foo.Bar; + + class Test { + private prop = foo; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 10, + line: 10, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + const foo = Foo.Bar; + + class Test { + private readonly prop = foo; + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + declare const foo: Foo; + + class Test { + private prop = foo; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 10, + line: 10, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + declare const foo: Foo; + + class Test { + private readonly prop = foo; + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + const bar = Foo.Bar; + + function wrapper() { + const Foo = 10; + + class Test { + private prop = bar; + + constructor() { + this.prop = bar; + } + } + } + `, + errors: [ + { + column: 13, + data: { + name: 'prop', + }, + endColumn: 25, + endLine: 13, + line: 13, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + const bar = Foo.Bar; + + function wrapper() { + const Foo = 10; + + class Test { + private readonly prop = bar; + + constructor() { + this.prop = bar; + } + } + } + `, + }, + { + code: ` + enum Foo { + Bar, + Bazz, + } + + const bar = Foo.Bar; + + function wrapper() { + type Foo = 10; + + class Test { + private prop = bar; + + constructor() { + this.prop = bar; + } + } + } + `, + errors: [ + { + column: 13, + data: { + name: 'prop', + }, + endColumn: 25, + endLine: 13, + line: 13, + messageId: 'preferReadonly', + }, + ], + output: ` + enum Foo { + Bar, + Bazz, + } + + const bar = Foo.Bar; + + function wrapper() { + type Foo = 10; + + class Test { + private readonly prop = bar; + + constructor() { + this.prop = bar; + } + } + } + `, + }, + { + code: ` + const Bar = (function () { + enum Foo { + Bar, + Bazz, + } + + return Foo; + })(); + + const bar = Bar.Bar; + + class Test { + private prop = bar; + + constructor() { + this.prop = bar; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 14, + line: 14, + messageId: 'preferReadonly', + }, + ], + output: ` + const Bar = (function () { + enum Foo { + Bar, + Bazz, + } + + return Foo; + })(); + + const bar = Bar.Bar; + + class Test { + private readonly prop = bar; + + constructor() { + this.prop = bar; + } + } + `, + }, + { + code: ` + class Test { + private prop = { foo: 'bar' }; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = { foo: 'bar' }; + } + `, + }, + { + code: ` + class Test { + private prop = { foo: 'bar' }; + + constructor() { + this.prop = { foo: 'bazz' }; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = { foo: 'bar' }; + + constructor() { + this.prop = { foo: 'bazz' }; + } + } + `, + }, + { + code: ` + class Test { + private prop = [1, 2, 'three']; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = [1, 2, 'three']; + } + `, + }, + { + code: ` + class Test { + private prop = [1, 2, 'three']; + + constructor() { + this.prop = [1, 2, 'four']; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = [1, 2, 'three']; + + constructor() { + this.prop = [1, 2, 'four']; + } + } + `, + }, + { + code: ` + class X { + private _isValid = true; + + getIsValid = () => this._isValid; + + constructor(data?: {}) { + if (!data) { + this._isValid = false; + } + } + } + `, + errors: [ + { + column: 11, + data: { + name: '_isValid', + }, + endColumn: 27, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class X { + private readonly _isValid: boolean = true; + + getIsValid = () => this._isValid; + + constructor(data?: {}) { + if (!data) { + this._isValid = false; + } + } + } + `, + }, + { + code: ` + class Test { + private prop: string = 'hello'; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop: string = 'hello'; + } + `, + }, + { + code: ` + class Test { + private prop: string | number = 'hello'; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop: string | number = 'hello'; + } + `, + }, + { + code: ` + class Test { + private prop: string; + + constructor() { + this.prop = 'hello'; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop: string; + + constructor() { + this.prop = 'hello'; + } + } + `, + }, + { + code: ` + class Test { + private prop; + + constructor() { + this.prop = 'hello'; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop; + + constructor() { + this.prop = 'hello'; + } + } + `, + }, + { + code: ` + class Test { + private prop; + + constructor(x: boolean) { + if (x) { + this.prop = 'hello'; + } else { + this.prop = 10; + } + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop; + + constructor(x: boolean) { + if (x) { + this.prop = 'hello'; + } else { + this.prop = 10; + } + } + } + `, + }, + { + code: ` + declare const hello: 'hello' | 10; + + class Test { + private prop = hello; + + constructor() { + this.prop = 10; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 5, + line: 5, + messageId: 'preferReadonly', + }, + ], + output: ` + declare const hello: 'hello' | 10; + + class Test { + private readonly prop = hello; + + constructor() { + this.prop = 10; + } + } + `, + }, + { + code: ` + class Test { + private prop = null; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = null; + } + `, + }, + { + code: ` + class Test { + private prop = null; + + constructor() { + this.prop = null; + } + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = null; + + constructor() { + this.prop = null; + } + } + `, + }, + { + code: ` + class Test { + private prop = 'hello' as string; + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = 'hello' as string; + } + `, + }, + { + code: ` + class Test { + private prop = Promise.resolve('hello'); + } + `, + errors: [ + { + column: 11, + data: { + name: 'prop', + }, + endColumn: 23, + endLine: 3, + line: 3, + messageId: 'preferReadonly', + }, + ], + output: ` + class Test { + private readonly prop = Promise.resolve('hello'); + } + `, + }, ], });