Skip to content

Commit 3df29ec

Browse files
authored
Fix readonly type detection for class and interface extends, closes #1326 (#1329)
1 parent 251ac75 commit 3df29ec

File tree

2 files changed

+56
-4
lines changed

2 files changed

+56
-4
lines changed

packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,5 +727,32 @@ ruleTesterWithTypes.run(RULE_NAME, rule, {
727727
}
728728
}
729729
`,
730+
tsx`
731+
type DeepReadOnly<T> = {
732+
readonly [P in keyof T]: T[P] extends (infer U)[]
733+
? ReadonlyArray<DeepReadOnly<U>>
734+
: T[P] extends ReadonlyArray<infer U>
735+
? ReadonlyArray<DeepReadOnly<U>>
736+
: T[P] extends object
737+
? DeepReadOnly<T[P]>
738+
: T[P];
739+
};
740+
741+
interface PressableProps {
742+
testID: string;
743+
}
744+
745+
type ReadonlyPressableProps = DeepReadOnly<PressableProps>;
746+
747+
interface ComponentProps extends ReadonlyPressableProps {
748+
readonly name: string;
749+
}
750+
751+
export function Component(props: ComponentProps) {
752+
const { name, testID } = props;
753+
754+
return <div data-testid={testID}>{name}</div>
755+
}
756+
`,
730757
],
731758
});

packages/plugins/eslint-plugin-react-x/src/rules/prefer-read-only-props.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript
55
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
66
import { getTypeImmutability, isImmutable, isReadonlyDeep, isReadonlyShallow, isUnknown } from "is-immutable-type";
77
import type { CamelCase } from "string-ts";
8+
import { isPropertyReadonlyInType } from "ts-api-utils";
89
import type ts from "typescript";
910

1011
import { createRule } from "../utils";
@@ -37,7 +38,9 @@ export default createRule<[], MessageID>({
3738

3839
export 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

6267
function 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

Comments
 (0)