Skip to content

feat(eslint-plugin): [array-type] detect Readonly<string[]> case #8752

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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
5 changes: 4 additions & 1 deletion packages/eslint-plugin/docs/rules/array-type.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ const y: readonly string[] = ['a', 'b'];

### `"generic"`

Always use `Array<T>` or `ReadonlyArray<T>` for all array types.
Always use `Array<T>`, `ReadonlyArray<T>`, or `Readonly<Array<T>>` for all array types.
`readonly T[]` will be modified to `ReadonlyArray<T>` and `Readonly<T[]>` will be modified to `Readonly<Array<T>`.

<Tabs>
<TabItem value="❌ Incorrect">

```ts option='{ "default": "generic" }'
const x: string[] = ['a', 'b'];
const y: readonly string[] = ['a', 'b'];
const z: Readonly<string[]> = ['a', 'b'];
```

</TabItem>
Expand All @@ -58,6 +60,7 @@ const y: readonly string[] = ['a', 'b'];
```ts option='{ "default": "generic" }'
const x: Array<string> = ['a', 'b'];
const y: ReadonlyArray<string> = ['a', 'b'];
const z: Readonly<Array<string>> = ['a', 'b'];
```

</TabItem>
Expand Down
28 changes: 21 additions & 7 deletions packages/eslint-plugin/src/rules/array-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ type MessageIds =
| 'errorStringArray'
| 'errorStringArraySimple'
| 'errorStringGeneric'
| 'errorStringGenericSimple';
| 'errorStringGenericSimple'
| 'errorStringArrayReadonly';

export default createRule<Options, MessageIds>({
name: 'array-type',
Expand All @@ -99,6 +100,8 @@ export default createRule<Options, MessageIds>({
"Array type using '{{readonlyPrefix}}{{type}}[]' is forbidden. Use '{{className}}<{{type}}>' instead.",
errorStringArray:
"Array type using '{{className}}<{{type}}>' is forbidden. Use '{{readonlyPrefix}}{{type}}[]' instead.",
errorStringArrayReadonly:
"Array type using '{{className}}<{{type}}>' is forbidden. Use '{{readonlyPrefix}}{{type}}' instead.",
errorStringArraySimple:
"Array type using '{{className}}<{{type}}>' is forbidden for simple types. Use '{{readonlyPrefix}}{{type}}[]' instead.",
errorStringGenericSimple:
Expand Down Expand Up @@ -199,13 +202,22 @@ export default createRule<Options, MessageIds>({
node.typeName.type !== AST_NODE_TYPES.Identifier ||
!(
node.typeName.name === 'Array' ||
node.typeName.name === 'ReadonlyArray'
)
node.typeName.name === 'ReadonlyArray' ||
node.typeName.name === 'Readonly'
) ||
(node.typeName.name === 'Readonly' &&
node.typeArguments?.params[0].type !== AST_NODE_TYPES.TSArrayType)
) {
return;
}

const isReadonlyArrayType = node.typeName.name === 'ReadonlyArray';
const isReadonlyWithGenericArrayType =
node.typeName.name === 'Readonly' &&
node.typeArguments?.params[0].type === AST_NODE_TYPES.TSArrayType;
Comment on lines +214 to +216
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] If the type name is Readonly and first type argument is not an array, we should return from the function. Because otherwise any Readonly<...> type will be reported.

const x: Readonly<string> = 'a'

playground

const isReadonlyArrayType =
node.typeName.name === 'ReadonlyArray' ||
isReadonlyWithGenericArrayType;

const currentOption = isReadonlyArrayType
? readonlyOption
: defaultOption;
Expand All @@ -218,7 +230,9 @@ export default createRule<Options, MessageIds>({
const typeParams = node.typeArguments?.params;
const messageId =
currentOption === 'array'
? 'errorStringArray'
? isReadonlyWithGenericArrayType
? 'errorStringArrayReadonly'
: 'errorStringArray'
: 'errorStringArraySimple';

if (!typeParams || typeParams.length === 0) {
Expand Down Expand Up @@ -256,13 +270,13 @@ export default createRule<Options, MessageIds>({
const start = `${parentParens ? '(' : ''}${readonlyPrefix}${
typeParens ? '(' : ''
}`;
const end = `${typeParens ? ')' : ''}[]${parentParens ? ')' : ''}`;
const end = `${typeParens ? ')' : ''}${isReadonlyWithGenericArrayType ? '' : `[]`}${parentParens ? ')' : ''}`;

context.report({
node,
messageId,
data: {
className: isReadonlyArrayType ? 'ReadonlyArray' : 'Array',
className: isReadonlyArrayType ? node.typeName.name : 'Array',
readonlyPrefix,
type: getMessageType(type),
},
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions packages/eslint-plugin/tests/rules/array-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ function bazFunction(baz: Arr<ArrayClass<String>>) {
code: 'let a: readonly Array<number>[] = [[]];',
options: [{ default: 'generic', readonly: 'array' }],
},
{
code: 'let a: Readonly = [];',
options: [{ default: 'generic', readonly: 'array' }],
},
{
code: "const x: Readonly<string> = 'a';",
options: [{ default: 'array' }],
},
],
invalid: [
// Base cases from https://github.com/typescript-eslint/typescript-eslint/issues/2323#issuecomment-663977655
Expand Down Expand Up @@ -1918,6 +1926,21 @@ interface FooInterface {
},
],
},
{
code: "const x: Readonly<string[]> = ['a', 'b'];",
output: "const x: readonly string[] = ['a', 'b'];",
options: [{ default: 'array' }],
errors: [
{
messageId: 'errorStringArrayReadonly',
data: {
className: 'Readonly',
readonlyPrefix: 'readonly ',
type: 'string[]',
},
},
],
},
],
});

Expand Down
6 changes: 3 additions & 3 deletions packages/rule-tester/src/RuleTester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,16 @@ export class RuleTester extends TestFramework {
/**
* Adds the `only` property to a test to run it in isolation.
*/
static only<Options extends Readonly<unknown[]>>(
static only<Options extends readonly unknown[]>(
item: ValidTestCase<Options> | string,
): ValidTestCase<Options>;
/**
* Adds the `only` property to a test to run it in isolation.
*/
static only<MessageIds extends string, Options extends Readonly<unknown[]>>(
static only<MessageIds extends string, Options extends readonly unknown[]>(
item: InvalidTestCase<MessageIds, Options>,
): InvalidTestCase<MessageIds, Options>;
static only<MessageIds extends string, Options extends Readonly<unknown[]>>(
static only<MessageIds extends string, Options extends readonly unknown[]>(
item:
| InvalidTestCase<MessageIds, Options>
| ValidTestCase<Options>
Expand Down
2 changes: 1 addition & 1 deletion packages/rule-tester/src/types/InvalidTestCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface TestCaseError<MessageIds extends string> {

export interface InvalidTestCase<
MessageIds extends string,
Options extends Readonly<unknown[]>,
Options extends readonly unknown[],
> extends ValidTestCase<Options> {
/**
* Expected errors.
Expand Down
2 changes: 1 addition & 1 deletion packages/rule-tester/src/types/ValidTestCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {

import type { DependencyConstraint } from './DependencyConstraint';

export interface ValidTestCase<Options extends Readonly<unknown[]>> {
export interface ValidTestCase<Options extends readonly unknown[]> {
/**
* Name for the test case.
*/
Expand Down
4 changes: 2 additions & 2 deletions packages/rule-tester/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type TesterConfigWithDefaults = Mutable<

export interface RunTests<
MessageIds extends string,
Options extends Readonly<unknown[]>,
Options extends readonly unknown[],
> {
// RuleTester.run also accepts strings for valid cases
readonly valid: readonly (ValidTestCase<Options> | string)[];
Expand All @@ -21,7 +21,7 @@ export interface RunTests<

export interface NormalizedRunTests<
MessageIds extends string,
Options extends Readonly<unknown[]>,
Options extends readonly unknown[],
> {
readonly valid: readonly ValidTestCase<Options>[];
readonly invalid: readonly InvalidTestCase<MessageIds, Options>[];
Expand Down
10 changes: 5 additions & 5 deletions packages/utils/src/ts-eslint/RuleTester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
SharedConfigurationSettings,
} from './Rule';

interface ValidTestCase<Options extends Readonly<unknown[]>> {
interface ValidTestCase<Options extends readonly unknown[]> {
/**
* Name for the test case.
*/
Expand Down Expand Up @@ -75,7 +75,7 @@ interface SuggestionOutput<MessageIds extends string> {

interface InvalidTestCase<
MessageIds extends string,
Options extends Readonly<unknown[]>,
Options extends readonly unknown[],
> extends ValidTestCase<Options> {
/**
* Expected errors.
Expand Down Expand Up @@ -135,7 +135,7 @@ type RuleTesterTestFrameworkFunction = (

interface RunTests<
MessageIds extends string,
Options extends Readonly<unknown[]>,
Options extends readonly unknown[],
> {
// RuleTester.run also accepts strings for valid cases
readonly valid: readonly (ValidTestCase<Options> | string)[];
Expand All @@ -160,7 +160,7 @@ declare class RuleTesterBase {
* @param rule The rule to test.
* @param test The collection of tests to run.
*/
run<MessageIds extends string, Options extends Readonly<unknown[]>>(
run<MessageIds extends string, Options extends readonly unknown[]>(
ruleName: string,
rule: RuleModule<MessageIds, Options>,
tests: RunTests<MessageIds, Options>,
Expand Down Expand Up @@ -190,7 +190,7 @@ declare class RuleTesterBase {
/**
* Define a rule for one particular run of tests.
*/
defineRule<MessageIds extends string, Options extends Readonly<unknown[]>>(
defineRule<MessageIds extends string, Options extends readonly unknown[]>(
name: string,
rule:
| RuleCreateFunction<MessageIds, Options>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class RuleDocsPage {
#headingIndices: RequiredHeadingIndices;
#rule: Readonly<RuleModuleWithMetaDocs>;

get children(): Readonly<unist.Node[]> {
get children(): readonly unist.Node[] {
return this.#children;
}

Expand Down
Loading