Skip to content

Commit 3be9854

Browse files
authored
feat(eslint-plugin): add prefer-readonly-parameters (typescript-eslint#1513)
1 parent b097245 commit 3be9854

File tree

9 files changed

+952
-19
lines changed

9 files changed

+952
-19
lines changed

packages/eslint-plugin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
142142
| [`@typescript-eslint/prefer-nullish-coalescing`](./docs/rules/prefer-nullish-coalescing.md) | Enforce the usage of the nullish coalescing operator instead of logical chaining | | :wrench: | :thought_balloon: |
143143
| [`@typescript-eslint/prefer-optional-chain`](./docs/rules/prefer-optional-chain.md) | Prefer using concise optional chain expressions instead of chained logical ands | | :wrench: | |
144144
| [`@typescript-eslint/prefer-readonly`](./docs/rules/prefer-readonly.md) | Requires that private members are marked as `readonly` if they're never modified outside of the constructor | | :wrench: | :thought_balloon: |
145+
| [`@typescript-eslint/prefer-readonly-parameter-types`](./docs/rules/prefer-readonly-parameter-types.md) | Requires that function parameters are typed as readonly to prevent accidental mutation of inputs | | | :thought_balloon: |
145146
| [`@typescript-eslint/prefer-regexp-exec`](./docs/rules/prefer-regexp-exec.md) | Enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided | :heavy_check_mark: | | :thought_balloon: |
146147
| [`@typescript-eslint/prefer-string-starts-ends-with`](./docs/rules/prefer-string-starts-ends-with.md) | Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings | :heavy_check_mark: | :wrench: | :thought_balloon: |
147148
| [`@typescript-eslint/promise-function-async`](./docs/rules/promise-function-async.md) | Requires any function or method that returns a Promise to be marked async | | | :thought_balloon: |
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Requires that function parameters are typed as readonly to prevent accidental mutation of inputs (`prefer-readonly-parameter-types`)
2+
3+
Mutating function arguments can lead to confusing, hard to debug behavior.
4+
Whilst it's easy to implicitly remember to not modify function arguments, explicitly typing arguments as readonly provides clear contract to consumers.
5+
This contract makes it easier for a consumer to reason about if a function has side-effects.
6+
7+
## Rule Details
8+
9+
This rule allows you to enforce that function parameters resolve to readonly types.
10+
A type is considered readonly if:
11+
12+
- it is a primitive type (`string`, `number`, `boolean`, `symbol`, or an enum),
13+
- it is a function signature type,
14+
- it is a readonly array type whose element type is considered readonly.
15+
- it is a readonly tuple type whose elements are all considered readonly.
16+
- it is an object type whose properties are all marked as readonly, and whose values are all considered readonly.
17+
18+
Examples of **incorrect** code for this rule:
19+
20+
```ts
21+
function array1(arg: string[]) {} // array is not readonly
22+
function array2(arg: readonly string[][]) {} // array element is not readonly
23+
function array3(arg: [string, number]) {} // tuple is not readonly
24+
function array4(arg: readonly [string[], number]) {} // tuple element is not readonly
25+
// the above examples work the same if you use ReadonlyArray<T> instead
26+
27+
function object1(arg: { prop: string }) {} // property is not readonly
28+
function object2(arg: { readonly prop: string; prop2: string }) {} // not all properties are readonly
29+
function object3(arg: { readonly prop: { prop2: string } }) {} // nested property is not readonly
30+
// the above examples work the same if you use Readonly<T> instead
31+
32+
interface CustomArrayType extends ReadonlyArray<string> {
33+
prop: string; // note: this property is mutable
34+
}
35+
function custom1(arg: CustomArrayType) {}
36+
37+
interface CustomFunction {
38+
(): void;
39+
prop: string; // note: this property is mutable
40+
}
41+
function custom2(arg: CustomFunction) {}
42+
43+
function union(arg: string[] | ReadonlyArray<number[]>) {} // not all types are readonly
44+
45+
// rule also checks function types
46+
interface Foo {
47+
(arg: string[]): void;
48+
}
49+
interface Foo {
50+
new (arg: string[]): void;
51+
}
52+
const x = { foo(arg: string[]): void; };
53+
function foo(arg: string[]);
54+
type Foo = (arg: string[]) => void;
55+
interface Foo {
56+
foo(arg: string[]): void;
57+
}
58+
```
59+
60+
Examples of **correct** code for this rule:
61+
62+
```ts
63+
function array1(arg: readonly string[]) {}
64+
function array2(arg: readonly (readonly string[])[]) {}
65+
function array3(arg: readonly [string, number]) {}
66+
function array4(arg: readonly [readonly string[], number]) {}
67+
// the above examples work the same if you use ReadonlyArray<T> instead
68+
69+
function object1(arg: { readonly prop: string }) {}
70+
function object2(arg: { readonly prop: string; readonly prop2: string }) {}
71+
function object3(arg: { readonly prop: { readonly prop2: string } }) {}
72+
// the above examples work the same if you use Readonly<T> instead
73+
74+
interface CustomArrayType extends ReadonlyArray<string> {
75+
readonly prop: string;
76+
}
77+
function custom1(arg: CustomArrayType) {}
78+
79+
interface CustomFunction {
80+
(): void;
81+
readonly prop: string;
82+
}
83+
function custom2(arg: CustomFunction) {}
84+
85+
function union(arg: readonly string[] | ReadonlyArray<number[]>) {}
86+
87+
function primitive1(arg: string) {}
88+
function primitive2(arg: number) {}
89+
function primitive3(arg: boolean) {}
90+
function primitive4(arg: unknown) {}
91+
function primitive5(arg: null) {}
92+
function primitive6(arg: undefined) {}
93+
function primitive7(arg: any) {}
94+
function primitive8(arg: never) {}
95+
function primitive9(arg: string | number | undefined) {}
96+
97+
function fnSig(arg: () => void) {}
98+
99+
enum Foo { a, b }
100+
function enum(arg: Foo) {}
101+
102+
function symb1(arg: symbol) {}
103+
const customSymbol = Symbol('a');
104+
function symb2(arg: typeof customSymbol) {}
105+
106+
// function types
107+
interface Foo {
108+
(arg: readonly string[]): void;
109+
}
110+
interface Foo {
111+
new (arg: readonly string[]): void;
112+
}
113+
const x = { foo(arg: readonly string[]): void; };
114+
function foo(arg: readonly string[]);
115+
type Foo = (arg: readonly string[]) => void;
116+
interface Foo {
117+
foo(arg: readonly string[]): void;
118+
}
119+
```
120+
121+
## Options
122+
123+
```ts
124+
interface Options {
125+
checkParameterProperties?: boolean;
126+
}
127+
128+
const defaultOptions: Options = {
129+
checkParameterProperties: true,
130+
};
131+
```
132+
133+
### `checkParameterProperties`
134+
135+
This option allows you to enable or disable the checking of parameter properties.
136+
Because parameter properties create properties on the class, it may be undesirable to force them to be readonly.
137+
138+
Examples of **incorrect** code for this rule with `{checkParameterProperties: true}`:
139+
140+
```ts
141+
class Foo {
142+
constructor(private paramProp: string[]) {}
143+
}
144+
```
145+
146+
Examples of **correct** code for this rule with `{checkParameterProperties: true}`:
147+
148+
```ts
149+
class Foo {
150+
constructor(private paramProp: readonly string[]) {}
151+
}
152+
```
153+
154+
Examples of **correct** code for this rule with `{checkParameterProperties: false}`:
155+
156+
```ts
157+
class Foo {
158+
constructor(
159+
private paramProp1: string[],
160+
private paramProp2: readonly string[],
161+
) {}
162+
}
163+
```

packages/eslint-plugin/src/configs/all.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"@typescript-eslint/prefer-nullish-coalescing": "error",
8080
"@typescript-eslint/prefer-optional-chain": "error",
8181
"@typescript-eslint/prefer-readonly": "error",
82+
"@typescript-eslint/prefer-readonly-parameter-types": "error",
8283
"@typescript-eslint/prefer-regexp-exec": "error",
8384
"@typescript-eslint/prefer-string-starts-ends-with": "error",
8485
"@typescript-eslint/promise-function-async": "error",

packages/eslint-plugin/src/rules/index.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import adjacentOverloadSignatures from './adjacent-overload-signatures';
22
import arrayType from './array-type';
33
import awaitThenable from './await-thenable';
4-
import banTsIgnore from './ban-ts-ignore';
54
import banTsComment from './ban-ts-comment';
5+
import banTsIgnore from './ban-ts-ignore';
66
import banTypes from './ban-types';
77
import braceStyle from './brace-style';
88
import camelcase from './camelcase';
@@ -29,10 +29,10 @@ import noDynamicDelete from './no-dynamic-delete';
2929
import noEmptyFunction from './no-empty-function';
3030
import noEmptyInterface from './no-empty-interface';
3131
import noExplicitAny from './no-explicit-any';
32+
import noExtraneousClass from './no-extraneous-class';
3233
import noExtraNonNullAssertion from './no-extra-non-null-assertion';
3334
import noExtraParens from './no-extra-parens';
3435
import noExtraSemi from './no-extra-semi';
35-
import noExtraneousClass from './no-extraneous-class';
3636
import noFloatingPromises from './no-floating-promises';
3737
import noForInArray from './no-for-in-array';
3838
import noImpliedEval from './no-implied-eval';
@@ -41,8 +41,8 @@ import noMagicNumbers from './no-magic-numbers';
4141
import noMisusedNew from './no-misused-new';
4242
import noMisusedPromises from './no-misused-promises';
4343
import noNamespace from './no-namespace';
44-
import noNonNullAssertion from './no-non-null-assertion';
4544
import noNonNullAssertedOptionalChain from './no-non-null-asserted-optional-chain';
45+
import noNonNullAssertion from './no-non-null-assertion';
4646
import noParameterProperties from './no-parameter-properties';
4747
import noRequireImports from './no-require-imports';
4848
import noThisAlias from './no-this-alias';
@@ -51,7 +51,7 @@ import noTypeAlias from './no-type-alias';
5151
import noUnnecessaryBooleanLiteralCompare from './no-unnecessary-boolean-literal-compare';
5252
import noUnnecessaryCondition from './no-unnecessary-condition';
5353
import noUnnecessaryQualifier from './no-unnecessary-qualifier';
54-
import useDefaultTypeParameter from './no-unnecessary-type-arguments';
54+
import noUnnecessaryTypeArguments from './no-unnecessary-type-arguments';
5555
import noUnnecessaryTypeAssertion from './no-unnecessary-type-assertion';
5656
import noUntypedPublicSignature from './no-untyped-public-signature';
5757
import noUnusedExpressions from './no-unused-expressions';
@@ -68,6 +68,7 @@ import preferNamespaceKeyword from './prefer-namespace-keyword';
6868
import preferNullishCoalescing from './prefer-nullish-coalescing';
6969
import preferOptionalChain from './prefer-optional-chain';
7070
import preferReadonly from './prefer-readonly';
71+
import preferReadonlyParameterTypes from './prefer-readonly-parameter-types';
7172
import preferRegexpExec from './prefer-regexp-exec';
7273
import preferStringStartsEndsWith from './prefer-string-starts-ends-with';
7374
import promiseFunctionAsync from './promise-function-async';
@@ -91,8 +92,8 @@ export default {
9192
'adjacent-overload-signatures': adjacentOverloadSignatures,
9293
'array-type': arrayType,
9394
'await-thenable': awaitThenable,
94-
'ban-ts-ignore': banTsIgnore,
9595
'ban-ts-comment': banTsComment,
96+
'ban-ts-ignore': banTsIgnore,
9697
'ban-types': banTypes,
9798
'no-base-to-string': noBaseToString,
9899
'brace-style': braceStyle,
@@ -125,28 +126,28 @@ export default {
125126
'no-extraneous-class': noExtraneousClass,
126127
'no-floating-promises': noFloatingPromises,
127128
'no-for-in-array': noForInArray,
128-
'no-inferrable-types': noInferrableTypes,
129129
'no-implied-eval': noImpliedEval,
130+
'no-inferrable-types': noInferrableTypes,
130131
'no-magic-numbers': noMagicNumbers,
131132
'no-misused-new': noMisusedNew,
132133
'no-misused-promises': noMisusedPromises,
133134
'no-namespace': noNamespace,
134-
'no-non-null-assertion': noNonNullAssertion,
135135
'no-non-null-asserted-optional-chain': noNonNullAssertedOptionalChain,
136+
'no-non-null-assertion': noNonNullAssertion,
136137
'no-parameter-properties': noParameterProperties,
137138
'no-require-imports': noRequireImports,
138139
'no-this-alias': noThisAlias,
139-
'no-type-alias': noTypeAlias,
140140
'no-throw-literal': noThrowLiteral,
141+
'no-type-alias': noTypeAlias,
141142
'no-unnecessary-boolean-literal-compare': noUnnecessaryBooleanLiteralCompare,
142143
'no-unnecessary-condition': noUnnecessaryCondition,
143144
'no-unnecessary-qualifier': noUnnecessaryQualifier,
144-
'no-unnecessary-type-arguments': useDefaultTypeParameter,
145+
'no-unnecessary-type-arguments': noUnnecessaryTypeArguments,
145146
'no-unnecessary-type-assertion': noUnnecessaryTypeAssertion,
146147
'no-untyped-public-signature': noUntypedPublicSignature,
147-
'no-unused-vars': noUnusedVars,
148-
'no-unused-vars-experimental': noUnusedVarsExperimental,
149148
'no-unused-expressions': noUnusedExpressions,
149+
'no-unused-vars-experimental': noUnusedVarsExperimental,
150+
'no-unused-vars': noUnusedVars,
150151
'no-use-before-define': noUseBeforeDefine,
151152
'no-useless-constructor': noUselessConstructor,
152153
'no-var-requires': noVarRequires,
@@ -157,6 +158,7 @@ export default {
157158
'prefer-namespace-keyword': preferNamespaceKeyword,
158159
'prefer-nullish-coalescing': preferNullishCoalescing,
159160
'prefer-optional-chain': preferOptionalChain,
161+
'prefer-readonly-parameter-types': preferReadonlyParameterTypes,
160162
'prefer-readonly': preferReadonly,
161163
'prefer-regexp-exec': preferRegexpExec,
162164
'prefer-string-starts-ends-with': preferStringStartsEndsWith,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
TSESTree,
3+
AST_NODE_TYPES,
4+
} from '@typescript-eslint/experimental-utils';
5+
import * as util from '../util';
6+
7+
type Options = [
8+
{
9+
checkParameterProperties?: boolean;
10+
},
11+
];
12+
type MessageIds = 'shouldBeReadonly';
13+
14+
export default util.createRule<Options, MessageIds>({
15+
name: 'prefer-readonly-parameter-types',
16+
meta: {
17+
type: 'suggestion',
18+
docs: {
19+
description:
20+
'Requires that function parameters are typed as readonly to prevent accidental mutation of inputs',
21+
category: 'Possible Errors',
22+
recommended: false,
23+
requiresTypeChecking: true,
24+
},
25+
schema: [
26+
{
27+
type: 'object',
28+
additionalProperties: false,
29+
properties: {
30+
checkParameterProperties: {
31+
type: 'boolean',
32+
},
33+
},
34+
},
35+
],
36+
messages: {
37+
shouldBeReadonly: 'Parameter should be a read only type',
38+
},
39+
},
40+
defaultOptions: [
41+
{
42+
checkParameterProperties: true,
43+
},
44+
],
45+
create(context, [{ checkParameterProperties }]) {
46+
const { esTreeNodeToTSNodeMap, program } = util.getParserServices(context);
47+
const checker = program.getTypeChecker();
48+
49+
return {
50+
[[
51+
AST_NODE_TYPES.ArrowFunctionExpression,
52+
AST_NODE_TYPES.FunctionDeclaration,
53+
AST_NODE_TYPES.FunctionExpression,
54+
AST_NODE_TYPES.TSCallSignatureDeclaration,
55+
AST_NODE_TYPES.TSConstructSignatureDeclaration,
56+
AST_NODE_TYPES.TSDeclareFunction,
57+
AST_NODE_TYPES.TSEmptyBodyFunctionExpression,
58+
AST_NODE_TYPES.TSFunctionType,
59+
AST_NODE_TYPES.TSMethodSignature,
60+
].join(', ')](
61+
node:
62+
| TSESTree.ArrowFunctionExpression
63+
| TSESTree.FunctionDeclaration
64+
| TSESTree.FunctionExpression
65+
| TSESTree.TSCallSignatureDeclaration
66+
| TSESTree.TSConstructSignatureDeclaration
67+
| TSESTree.TSDeclareFunction
68+
| TSESTree.TSEmptyBodyFunctionExpression
69+
| TSESTree.TSFunctionType
70+
| TSESTree.TSMethodSignature,
71+
): void {
72+
for (const param of node.params) {
73+
if (
74+
!checkParameterProperties &&
75+
param.type === AST_NODE_TYPES.TSParameterProperty
76+
) {
77+
continue;
78+
}
79+
80+
const actualParam =
81+
param.type === AST_NODE_TYPES.TSParameterProperty
82+
? param.parameter
83+
: param;
84+
const tsNode = esTreeNodeToTSNodeMap.get(actualParam);
85+
const type = checker.getTypeAtLocation(tsNode);
86+
const isReadOnly = util.isTypeReadonly(checker, type);
87+
88+
if (!isReadOnly) {
89+
context.report({
90+
node: actualParam,
91+
messageId: 'shouldBeReadonly',
92+
});
93+
}
94+
}
95+
},
96+
};
97+
},
98+
});

packages/eslint-plugin/src/util/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ESLintUtils } from '@typescript-eslint/experimental-utils';
22

33
export * from './astUtils';
44
export * from './createRule';
5+
export * from './isTypeReadonly';
56
export * from './misc';
67
export * from './nullThrows';
78
export * from './types';

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy