diff --git a/packages/eslint-plugin/src/rules/prefer-readonly.ts b/packages/eslint-plugin/src/rules/prefer-readonly.ts index be92e541781c..ea4ff0e1bc4c 100644 --- a/packages/eslint-plugin/src/rules/prefer-readonly.ts +++ b/packages/eslint-plugin/src/rules/prefer-readonly.ts @@ -251,6 +251,13 @@ type ParameterOrPropertyDeclaration = const OUTSIDE_CONSTRUCTOR = -1; const DIRECTLY_INSIDE_CONSTRUCTOR = 0; +enum TypeToClassRelation { + ClassAndInstance, + Class, + Instance, + None, +} + class ClassScope { private readonly privateModifiableMembers = new Map< string, @@ -312,29 +319,75 @@ class ClassScope { ).set(node.name.getText(), node); } + public getTypeToClassRelation(type: ts.Type): TypeToClassRelation { + if (type.isIntersection()) { + let result: TypeToClassRelation = TypeToClassRelation.None; + for (const subType of type.types) { + const subTypeResult = this.getTypeToClassRelation(subType); + switch (subTypeResult) { + case TypeToClassRelation.Class: + if (result === TypeToClassRelation.Instance) { + return TypeToClassRelation.ClassAndInstance; + } + result = TypeToClassRelation.Class; + break; + case TypeToClassRelation.Instance: + if (result === TypeToClassRelation.Class) { + return TypeToClassRelation.ClassAndInstance; + } + result = TypeToClassRelation.Instance; + break; + } + } + return result; + } + if (type.isUnion()) { + // any union of class/instance and something else will prevent access to + // private members, so we assume that union consists only of classes + // or class instances, because otherwise tsc will report an error + return this.getTypeToClassRelation(type.types[0]); + } + + if (!type.getSymbol() || !typeIsOrHasBaseType(type, this.classType)) { + return TypeToClassRelation.None; + } + + const typeIsClass = + tsutils.isObjectType(type) && + tsutils.isObjectFlagSet(type, ts.ObjectFlags.Anonymous); + + if (typeIsClass) { + return TypeToClassRelation.Class; + } + + return TypeToClassRelation.Instance; + } + public addVariableModification(node: ts.PropertyAccessExpression): void { const modifierType = this.checker.getTypeAtLocation(node.expression); + + const relationOfModifierTypeToClass = + this.getTypeToClassRelation(modifierType); + if ( - !modifierType.getSymbol() || - !typeIsOrHasBaseType(modifierType, this.classType) + relationOfModifierTypeToClass === TypeToClassRelation.Instance && + this.constructorScopeDepth === DIRECTLY_INSIDE_CONSTRUCTOR ) { return; } - const modifyingStatic = - tsutils.isObjectType(modifierType) && - tsutils.isObjectFlagSet(modifierType, ts.ObjectFlags.Anonymous); if ( - !modifyingStatic && - this.constructorScopeDepth === DIRECTLY_INSIDE_CONSTRUCTOR + relationOfModifierTypeToClass === TypeToClassRelation.Instance || + relationOfModifierTypeToClass === TypeToClassRelation.ClassAndInstance ) { - return; + this.memberVariableModifications.add(node.name.text); + } + if ( + relationOfModifierTypeToClass === TypeToClassRelation.Class || + relationOfModifierTypeToClass === TypeToClassRelation.ClassAndInstance + ) { + this.staticVariableModifications.add(node.name.text); } - - (modifyingStatic - ? this.staticVariableModifications - : this.memberVariableModifications - ).add(node.name.text); } public enterConstructor( diff --git a/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts b/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts index d35ffa66edb6..a8d170c4a7d0 100644 --- a/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-readonly.test.ts @@ -651,6 +651,72 @@ class Foo { } `, }, + ` + class TestIntersection { + private prop: number = 3; + + test() { + const that = {} as this & { _foo: 'bar' }; + that.prop = 1; + } + } + `, + ` + class TestUnion { + private prop: number = 3; + + test() { + const that = {} as this | (this & { _foo: 'bar' }); + that.prop = 1; + } + } + `, + ` + class TestStaticIntersection { + private static prop: number; + + test() { + const that = {} as typeof TestStaticIntersection & { _foo: 'bar' }; + that.prop = 1; + } + } + `, + ` + class TestStaticUnion { + private static prop: number = 1; + + test() { + const that = {} as + | typeof TestStaticUnion + | (typeof TestStaticUnion & { _foo: 'bar' }); + that.prop = 1; + } + } + `, + ` + class TestBothIntersection { + private prop1: number = 1; + private static prop2: number; + + test() { + const that = {} as typeof TestBothIntersection & this; + that.prop1 = 1; + that.prop2 = 1; + } + } + `, + ` + class TestBothIntersection { + private prop1: number = 1; + private static prop2: number; + + test() { + const that = {} as this & typeof TestBothIntersection; + that.prop1 = 1; + that.prop2 = 1; + } + } + `, ], invalid: [ { @@ -1978,5 +2044,98 @@ function ClassWithName {}>(Base: TBase) { }, ], }, + { + code: ` + class Test { + private prop: number = 3; + + test() { + const that = {} as this & { _foo: 'bar' }; + that._foo = 1; + } + } + `, + output: ` + class Test { + private readonly prop: number = 3 + + test() { + const that = {} as this & {_foo: 'bar'} + that._foo = 1 + } + } + `, + errors: [ + { + data: { + name: 'prop', + }, + line: 3, + messageId: 'preferReadonly', + }, + ], + }, + { + code: ` + class Test { + private prop: number = 3; + + test() { + const that = {} as this | (this & { _foo: 'bar' }); + that.prop; + } + } + `, + output: ` + class Test { + private readonly prop: number = 3 + + test() { + const that = {} as this | (this & {_foo: 'bar'}) + that.prop + } + } + `, + errors: [ + { + data: { + name: 'prop', + }, + line: 3, + messageId: 'preferReadonly', + }, + ], + }, + { + code: ` + class Test { + private prop: number; + + constructor() { + const that = {} as this & { _foo: 'bar' }; + that.prop = 1; + } + } + `, + output: ` + class Test { + private readonly prop: number + + constructor() { + const that = {} as this & {_foo: 'bar'} + that.prop = 1 + } + } + `, + errors: [ + { + data: { + name: 'prop', + }, + line: 3, + messageId: 'preferReadonly', + }, + ], + }, ], });