Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -727,5 +727,32 @@ ruleTesterWithTypes.run(RULE_NAME, rule, {
}
}
`,
tsx`
type DeepReadOnly<T> = {
readonly [P in keyof T]: T[P] extends (infer U)[]
? ReadonlyArray<DeepReadOnly<U>>
: T[P] extends ReadonlyArray<infer U>
? ReadonlyArray<DeepReadOnly<U>>
: T[P] extends object
? DeepReadOnly<T[P]>
: T[P];
};

interface PressableProps {
testID: string;
}

type ReadonlyPressableProps = DeepReadOnly<PressableProps>;

interface ComponentProps extends ReadonlyPressableProps {
readonly name: string;
}

export function Component(props: ComponentProps) {
const { name, testID } = props;

return <div data-testid={testID}>{name}</div>
}
`,
],
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ESLintUtils, type ParserServicesWithTypeInformation } from "@typescript
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
import { getTypeImmutability, isImmutable, isReadonlyDeep, isReadonlyShallow, isUnknown } from "is-immutable-type";
import type { CamelCase } from "string-ts";
import { isPropertyReadonlyInType } from "ts-api-utils";
import type ts from "typescript";

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

export function create(context: RuleContext<MessageID, []>): RuleListener {
const services = ESLintUtils.getParserServices(context, false);
const checker = services.program.getTypeChecker();
const { ctx, listeners } = useComponentCollector(context);

return {
...listeners,
"Program:exit"(program) {
Expand All @@ -50,21 +53,43 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
continue;
}
const propsType = getConstrainedTypeAtLocation(services, props);
if (isTypeReadonlyLoose(services, propsType)) {
continue;
}
if (isTypeReadonly(services.program, propsType)) continue;
// Handle edge case where isTypeReadonly cant detect some readonly or immutable types
if (isTypeReadonlyLoose(services, propsType)) continue;
// @see https://github.com/Rel1cx/eslint-react/issues/1326
if (propsType.isClassOrInterface() && isClassOrInterfaceReadonlyLoose(checker, propsType)) continue;
context.report({ messageId: "preferReadOnlyProps", node: props });
}
},
};
}

function isTypeReadonlyLoose(services: ParserServicesWithTypeInformation, type: ts.Type): boolean {
if (isTypeReadonly(services.program, type)) return true;
try {
const im = getTypeImmutability(services.program, type);
return isUnknown(im) || isImmutable(im) || isReadonlyShallow(im) || isReadonlyDeep(im);
} catch {
return true;
}
}

// TODO: A comprehensive test is required to verify that it works as expected
// @see https://github.com/Rel1cx/eslint-react/issues/1326
function isClassOrInterfaceReadonlyLoose(checker: ts.TypeChecker, type: ts.Type) {
const baseTypes = type.getBaseTypes() ?? [];
const properties = type.getProperties();
if (properties.length === 0) {
return true;
}
if (baseTypes.length === 0) {
return properties.every((property) => isPropertyReadonlyInType(type, property.getEscapedName(), checker));
}
for (const property of properties) {
const propertyName = property.getEscapedName();
if (isPropertyReadonlyInType(type, propertyName, checker)) continue;
else if (baseTypes.length > 0) {
return baseTypes.every((heritageType) => isPropertyReadonlyInType(heritageType, propertyName, checker));
}
}
return true;
}