@@ -5,6 +5,7 @@ import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript
55import type { RuleListener } from "@typescript-eslint/utils/ts-eslint" ;
66import { getTypeImmutability , isImmutable , isReadonlyDeep , isReadonlyShallow , isUnknown } from "is-immutable-type" ;
77import type { CamelCase } from "string-ts" ;
8+ import { isPropertyReadonlyInType } from "ts-api-utils" ;
89import type ts from "typescript" ;
910
1011import { createRule } from "../utils" ;
@@ -37,7 +38,9 @@ export default createRule<[], MessageID>({
3738
3839export function create ( context : RuleContext < MessageID , [ ] > ) : RuleListener {
3940 const services = ESLintUtils . getParserServices ( context , false ) ;
41+ const checker = services . program . getTypeChecker ( ) ;
4042 const { ctx, listeners } = useComponentCollector ( context ) ;
43+
4144 return {
4245 ...listeners ,
4346 "Program:exit" ( program ) {
@@ -50,21 +53,43 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
5053 continue ;
5154 }
5255 const propsType = getConstrainedTypeAtLocation ( services , props ) ;
53- if ( isTypeReadonlyLoose ( services , propsType ) ) {
54- continue ;
55- }
56+ if ( isTypeReadonly ( services . program , propsType ) ) continue ;
57+ // Handle edge case where isTypeReadonly cant detect some readonly or immutable types
58+ if ( isTypeReadonlyLoose ( services , propsType ) ) continue ;
59+ // @see https://github.com/Rel1cx/eslint-react/issues/1326
60+ if ( propsType . isClassOrInterface ( ) && isClassOrInterfaceReadonlyLoose ( checker , propsType ) ) continue ;
5661 context . report ( { messageId : "preferReadOnlyProps" , node : props } ) ;
5762 }
5863 } ,
5964 } ;
6065}
6166
6267function isTypeReadonlyLoose ( services : ParserServicesWithTypeInformation , type : ts . Type ) : boolean {
63- if ( isTypeReadonly ( services . program , type ) ) return true ;
6468 try {
6569 const im = getTypeImmutability ( services . program , type ) ;
6670 return isUnknown ( im ) || isImmutable ( im ) || isReadonlyShallow ( im ) || isReadonlyDeep ( im ) ;
6771 } catch {
6872 return true ;
6973 }
7074}
75+
76+ // TODO: A comprehensive test is required to verify that it works as expected
77+ // @see https://github.com/Rel1cx/eslint-react/issues/1326
78+ function isClassOrInterfaceReadonlyLoose ( checker : ts . TypeChecker , type : ts . Type ) {
79+ const baseTypes = type . getBaseTypes ( ) ?? [ ] ;
80+ const properties = type . getProperties ( ) ;
81+ if ( properties . length === 0 ) {
82+ return true ;
83+ }
84+ if ( baseTypes . length === 0 ) {
85+ return properties . every ( ( property ) => isPropertyReadonlyInType ( type , property . getEscapedName ( ) , checker ) ) ;
86+ }
87+ for ( const property of properties ) {
88+ const propertyName = property . getEscapedName ( ) ;
89+ if ( isPropertyReadonlyInType ( type , propertyName , checker ) ) continue ;
90+ else if ( baseTypes . length > 0 ) {
91+ return baseTypes . every ( ( heritageType ) => isPropertyReadonlyInType ( heritageType , propertyName , checker ) ) ;
92+ }
93+ }
94+ return true ;
95+ }
0 commit comments