Skip to content

Commit a6a57a9

Browse files
feat(prefer-readonly-return-types): create new rule
1 parent 39501b3 commit a6a57a9

File tree

14 files changed

+945
-7
lines changed

14 files changed

+945
-7
lines changed

.eslintrc.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@
9090
"jsdoc/require-jsdoc": "off"
9191
}
9292
},
93+
{
94+
"files": ["**/*.md/**"],
95+
"rules": {
96+
"@typescript-eslint/array-type": "off",
97+
"functional/no-mixed-type": "off"
98+
}
99+
},
93100
// FIXME: This override is defined in the upsteam; it shouldn't need to be redefined here. Why?
94101
{
95102
"files": ["./**/*.md/**"],

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,13 @@ The [below section](#supported-rules) gives details on which rules are enabled b
186186

187187
:see_no_evil: = `no-mutations` Ruleset.
188188

189-
| Name | Description | <span title="No Mutations">:see_no_evil:</span> | <span title="Lite">:hear_no_evil:</span> | <span title="Recommended">:speak_no_evil:</span> | :wrench: | :blue_heart: |
190-
| -------------------------------------------------------------- | -------------------------------------------------------------------------- | :---------------------------------------------: | :--------------------------------------: | :----------------------------------------------: | :------: | :---------------: |
191-
| [`immutable-data`](./docs/rules/immutable-data.md) | Disallow mutating objects and arrays | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :blue_heart: |
192-
| [`no-let`](./docs/rules/no-let.md) | Disallow mutable variables | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | |
193-
| [`no-method-signature`](./docs/rules/no-method-signature.md) | Enforce property signatures with readonly modifiers over method signatures | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :thought_balloon: |
194-
| [`prefer-readonly-type`](./docs/rules/prefer-readonly-type.md) | Use readonly types and readonly modifiers where possible | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :wrench: | :thought_balloon: |
189+
| Name | Description | <span title="No Mutations">:see_no_evil:</span> | <span title="Lite">:hear_no_evil:</span> | <span title="Recommended">:speak_no_evil:</span> | :wrench: | :blue_heart: |
190+
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | :---------------------------------------------: | :--------------------------------------: | :----------------------------------------------: | :------: | :---------------: |
191+
| [`immutable-data`](./docs/rules/immutable-data.md) | Disallow mutating objects and arrays | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :blue_heart: |
192+
| [`no-let`](./docs/rules/no-let.md) | Disallow mutable variables | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | |
193+
| [`no-method-signature`](./docs/rules/no-method-signature.md) | Enforce property signatures with readonly modifiers over method signatures | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | :thought_balloon: |
194+
| [`prefer-readonly-type`](./docs/rules/prefer-readonly-type.md) | Use readonly types and readonly modifiers where possible | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :wrench: | :thought_balloon: |
195+
| [`prefer-readonly-return-types`](./docs/rules/prefer-readonly-return-types.md) | Enforce use of readonly types for function return values | | | | | :thought_balloon: |
195196

196197
### No Object-Orientation Rules
197198

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# Requires that function return values are typed as readonly (prefer-readonly-return-types)
2+
3+
This rules work just like [prefer-readonly-parameter-types](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md).
4+
5+
This rule should only be used in a purely functional environment to help ensure that values are not being mutated.
6+
7+
## Rule Details
8+
9+
This rule allows you to enforce that function return values resolve to a readonly type.
10+
11+
Examples of **incorrect** code for this rule:
12+
13+
<!-- eslint-skip -->
14+
15+
```ts
16+
/* eslint functional/prefer-readonly-type-declaration: "error" */
17+
18+
function array1(): string[] {} // array is not readonly
19+
function array2(): readonly string[][] {} // array element is not readonly
20+
function array3(): [string, number] {} // tuple is not readonly
21+
function array4(): readonly [string[], number] {} // tuple element is not readonly
22+
// the above examples work the same if you use ReadonlyArray<T> instead
23+
24+
function object1(): { prop: string } {} // property is not readonly
25+
function object2(): { readonly prop: string; prop2: string } {} // not all properties are readonly
26+
function object3(): { readonly prop: { prop2: string } } {} // nested property is not readonly
27+
// the above examples work the same if you use Readonly<T> instead
28+
29+
interface CustomArrayType extends ReadonlyArray<string> {
30+
prop: string; // note: this property is mutable
31+
}
32+
function custom1(): CustomArrayType {}
33+
34+
interface CustomFunction {
35+
(): void;
36+
prop: string; // note: this property is mutable
37+
}
38+
function custom2(): CustomFunction {}
39+
40+
function union(): string[] | ReadonlyArray<number[]> {} // not all types are readonly
41+
42+
// rule also checks function types
43+
interface Foo {
44+
(): string[];
45+
}
46+
interface Foo {
47+
new (): string[];
48+
}
49+
const x = { foo(): string[]; };
50+
function foo(): string[];
51+
type Foo = () => string[];
52+
interface Foo {
53+
foo(): string[];
54+
}
55+
```
56+
57+
Examples of **correct** code for this rule:
58+
59+
```ts
60+
/* eslint functional/prefer-readonly-return-types: "error" */
61+
62+
function array1(): readonly string[] {}
63+
function array2(): readonly (readonly string[])[] {}
64+
function array3(): readonly [string, number] {}
65+
function array4(): readonly [readonly string[], number] {}
66+
// the above examples work the same if you use ReadonlyArray<T> instead
67+
68+
function object1(): { readonly prop: string } {}
69+
function object2(): { readonly prop: string; readonly prop2: string } {}
70+
function object3(): { readonly prop: { readonly prop2: string } } {}
71+
// the above examples work the same if you use Readonly<T> instead
72+
73+
interface CustomArrayType extends ReadonlyArray<string> {
74+
readonly prop: string;
75+
}
76+
function custom1(): Readonly<CustomArrayType> {}
77+
// interfaces that extend the array types are not considered arrays, and thus must be made readonly.
78+
79+
interface CustomFunction {
80+
(): void;
81+
readonly prop: string;
82+
}
83+
function custom2(): CustomFunction {}
84+
85+
function union(): readonly string[] | ReadonlyArray<number[]> {}
86+
87+
function primitive1(): string {}
88+
function primitive2(): number {}
89+
function primitive3(): boolean {}
90+
function primitive4(): unknown {}
91+
function primitive5(): null {}
92+
function primitive6(): undefined {}
93+
function primitive7(): any {}
94+
function primitive8(): never {}
95+
function primitive9(): number | string | undefined {}
96+
97+
function fnSig(): () => void {}
98+
99+
enum Foo { A, B }
100+
function enum1(): Foo {}
101+
102+
function symb1(): symbol {}
103+
const customSymbol = Symbol('a');
104+
function symb2(): typeof customSymbol {}
105+
106+
// function types
107+
interface Foo {
108+
(): readonly string[];
109+
}
110+
interface Foo {
111+
new (): readonly string[];
112+
}
113+
const x = { foo(): readonly string[]; };
114+
function foo(): readonly string[];
115+
type Foo = () => readonly string[];
116+
interface Foo {
117+
foo(): readonly string[];
118+
}
119+
```
120+
121+
The default options:
122+
123+
```ts
124+
const defaults = {
125+
allowLocalMutation: false,
126+
ignoreClass: false,
127+
ignoreCollections: false,
128+
ignoreInferredTypes: false,
129+
ignoreInterface: false,
130+
treatMethodsAsReadonly: false,
131+
}
132+
```
133+
134+
### `treatMethodsAsReadonly`
135+
136+
This option allows you to treat all mutable methods as though they were readonly. This may be desirable in when you are never reassigning methods.
137+
138+
Examples of **incorrect** code for this rule with `{treatMethodsAsReadonly: false}`:
139+
140+
```ts
141+
type MyType = {
142+
readonly prop: string;
143+
method(): string; // note: this method is mutable
144+
};
145+
function foo(arg: MyType) {}
146+
```
147+
148+
Examples of **correct** code for this rule with `{treatMethodsAsReadonly: false}`:
149+
150+
```ts
151+
type MyType = Readonly<{
152+
prop: string;
153+
method(): string;
154+
}>;
155+
function foo(): MyType {}
156+
type MyOtherType = {
157+
readonly prop: string;
158+
readonly method: () => string;
159+
};
160+
function bar(): MyOtherType {}
161+
```
162+
163+
Examples of **correct** code for this rule with `{treatMethodsAsReadonly: true}`:
164+
165+
```ts
166+
type MyType = {
167+
readonly prop: string;
168+
method(): string; // note: this method is mutable
169+
};
170+
function foo(): MyType {}
171+
```
172+
173+
### `ignoreClass`
174+
175+
If set, classes will not be checked.
176+
177+
Examples of **incorrect** code for the `{ "ignoreClass": false }` option:
178+
179+
<!-- eslint-skip -->
180+
181+
```ts
182+
/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreClass": false }] */
183+
184+
class {
185+
myprop: string;
186+
}
187+
```
188+
189+
Examples of **correct** code for the `{ "ignoreClass": true }` option:
190+
191+
```ts
192+
/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreClass": true }] */
193+
194+
class {
195+
myprop: string;
196+
}
197+
```
198+
199+
### `ignoreInterface`
200+
201+
If set, interfaces will not be checked.
202+
203+
Examples of **incorrect** code for the `{ "ignoreInterface": false }` option:
204+
205+
<!-- eslint-skip -->
206+
207+
```ts
208+
/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreInterface": false }] */
209+
210+
interface I {
211+
myprop: string;
212+
}
213+
```
214+
215+
Examples of **correct** code for the `{ "ignoreInterface": true }` option:
216+
217+
```ts
218+
/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreInterface": true }] */
219+
220+
interface I {
221+
myprop: string;
222+
}
223+
```
224+
225+
### `ignoreCollections`
226+
227+
If set, collections (Array, Tuple, Set, and Map) will not be required to be readonly when used outside of type aliases and interfaces.
228+
229+
Examples of **incorrect** code for the `{ "ignoreCollections": false }` option:
230+
231+
<!-- eslint-skip -->
232+
233+
```ts
234+
/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreCollections": false }] */
235+
236+
const foo: number[] = [];
237+
const bar: [string, string] = ["foo", "bar"];
238+
const baz: Set<string, string> = new Set();
239+
const qux: Map<string, string> = new Map();
240+
```
241+
242+
Examples of **correct** code for the `{ "ignoreCollections": true }` option:
243+
244+
```ts
245+
/* eslint functional/prefer-readonly-type-declaration: ["error", { "ignoreCollections": true }] */
246+
247+
const foo: number[] = [];
248+
const bar: [string, string] = ["foo", "bar"];
249+
const baz: Set<string, string> = new Set();
250+
const qux: Map<string, string> = new Map();
251+
```
252+
253+
### `ignoreInferredTypes`
254+
255+
This option allows you to ignore types that aren't explicitly specified.
256+
257+
### `allowLocalMutation`
258+
259+
See the [allowLocalMutation](./options/allow-local-mutation.md) docs.
260+
261+
### `ignorePattern`
262+
263+
Use the given regex pattern(s) to match against the type's name (for objects this is the property's name not the object's name).
264+
265+
Note: If using this option to require mutable properties are marked as mutable via a naming convention (e.g. `{ "ignorePattern": "^[Mm]utable.+" }`),
266+
type aliases and interfaces names will still need to comply with the `readonlyAliasPatterns` and `mutableAliasPatterns` options.
267+
268+
See the [ignorePattern](./options/ignore-pattern.md) docs for more info.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
},
6464
"dependencies": {
6565
"@typescript-eslint/utils": "^5.10.0",
66+
"@typescript-eslint/type-utils": "^5.10.0",
6667
"deepmerge-ts": "^2.0.1",
6768
"escape-string-regexp": "^4.0.0"
6869
},

src/common/ignore-options.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ export const ignoreInterfaceOptionSchema: JSONSchema4["properties"] = {
125125
},
126126
};
127127

128+
/**
129+
* The option to ignore inferred types.
130+
*/
131+
export type IgnoreInferredTypesOption = {
132+
readonly ignoreInferredTypes: boolean;
133+
};
134+
135+
/**
136+
* The schema for the option to ignore inferred types.
137+
*/
138+
export const ignoreInferredTypesOptionSchema: JSONSchema4["properties"] = {
139+
ignoreInferredTypes: {
140+
type: "boolean",
141+
},
142+
};
143+
128144
/**
129145
* Get the identifier text of the given node.
130146
*/
@@ -322,6 +338,19 @@ export function shouldIgnoreInterface(
322338
return ignoreInterface === true && inInterface(node);
323339
}
324340

341+
/**
342+
* Should the given node be allowed base off the following rule options?
343+
*
344+
* - IgnoreInterfaceOption.
345+
*/
346+
export function shouldIgnoreInferredTypes(
347+
node: ReadonlyDeep<TSESTree.Node>,
348+
context: ReadonlyDeep<TSESLint.RuleContext<string, BaseOptions>>,
349+
{ ignoreInferredTypes }: Partial<IgnoreInferredTypesOption>
350+
) {
351+
return ignoreInferredTypes === true && node === null;
352+
}
353+
325354
/**
326355
* Should the given node be allowed base off the following rule options?
327356
*

src/configs/all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const config: Linter.Config = {
2121
rules: {
2222
"functional/no-method-signature": "error",
2323
"functional/no-mixed-type": "error",
24+
"functional/prefer-readonly-return-types": "error",
2425
"functional/prefer-readonly-type": "error",
2526
"functional/prefer-tacit": ["error", { assumeTypes: false }],
2627
"functional/no-return-void": "error",

src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as noReturnVoid from "./no-return-void";
1212
import * as noThisExpression from "./no-this-expression";
1313
import * as noThrowStatement from "./no-throw-statement";
1414
import * as noTryStatement from "./no-try-statement";
15+
import * as preferReadonlyReturnTypes from "./prefer-readonly-return-types";
1516
import * as preferReadonlyTypes from "./prefer-readonly-type";
1617
import * as preferTacit from "./prefer-tacit";
1718

@@ -33,6 +34,7 @@ export const rules = {
3334
[noThisExpression.name]: noThisExpression.rule,
3435
[noThrowStatement.name]: noThrowStatement.rule,
3536
[noTryStatement.name]: noTryStatement.rule,
37+
[preferReadonlyReturnTypes.name]: preferReadonlyReturnTypes.rule,
3638
[preferReadonlyTypes.name]: preferReadonlyTypes.rule,
3739
[preferTacit.name]: preferTacit.rule,
3840
};

0 commit comments

Comments
 (0)