Skip to content

Commit 31fa3dc

Browse files
wip
1 parent 0e2c490 commit 31fa3dc

File tree

9 files changed

+329
-0
lines changed

9 files changed

+329
-0
lines changed

src/configs/all.ts

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

src/configs/no-mutations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const config: Linter.Config = {
1111
rules: {
1212
"functional/no-method-signature": "warn",
1313
"functional/prefer-readonly-type": "error",
14+
"functional/prefer-readonly-type-alias": "error",
1415
},
1516
},
1617
],

src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as noThisExpression from "./no-this-expression";
1313
import * as noThrowStatement from "./no-throw-statement";
1414
import * as noTryStatement from "./no-try-statement";
1515
import * as preferReadonlyTypes from "./prefer-readonly-type";
16+
import * as preferReadonlyTypeAlias from "./prefer-readonly-type-alias";
1617
import * as preferTacit from "./prefer-tacit";
1718

1819
/**
@@ -34,5 +35,6 @@ export const rules = {
3435
[noThrowStatement.name]: noThrowStatement.rule,
3536
[noTryStatement.name]: noTryStatement.rule,
3637
[preferReadonlyTypes.name]: preferReadonlyTypes.rule,
38+
[preferReadonlyTypeAlias.name]: preferReadonlyTypeAlias.rule,
3739
[preferTacit.name]: preferTacit.rule,
3840
};
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import type { TSESTree } from "@typescript-eslint/experimental-utils";
2+
import type { JSONSchema4 } from "json-schema";
3+
4+
import { isReadonly, RuleContext, RuleMetaData, RuleResult } from "~/util/rule";
5+
import { createRule } from "~/util/rule";
6+
7+
// The name of this rule.
8+
export const name = "prefer-readonly-type-alias" as const;
9+
10+
const enum RequiredReadonlyness {
11+
READONLY,
12+
MUTABLE,
13+
EITHER,
14+
}
15+
16+
// The options this rule can take.
17+
type Options = {
18+
readonly mustBeReadonly: {
19+
readonly pattern: ReadonlyArray<string> | string;
20+
readonly requireOthersToBeMutable: boolean;
21+
};
22+
readonly mustBeMutable: {
23+
readonly pattern: ReadonlyArray<string> | string;
24+
readonly requireOthersToBeReadonly: boolean;
25+
};
26+
};
27+
28+
// The schema for the rule options.
29+
const schema: JSONSchema4 = [
30+
{
31+
type: "object",
32+
properties: {
33+
mustBeReadonly: {
34+
type: "object",
35+
properties: {
36+
pattern: {
37+
type: ["string", "array"],
38+
items: {
39+
type: "string",
40+
},
41+
},
42+
requireOthersToBeMutable: {
43+
type: "boolean",
44+
},
45+
},
46+
additionalProperties: false,
47+
},
48+
mustBeMutable: {
49+
type: "object",
50+
properties: {
51+
pattern: {
52+
type: ["string", "array"],
53+
items: {
54+
type: "string",
55+
},
56+
},
57+
requireOthersToBeReadonly: {
58+
type: "boolean",
59+
},
60+
},
61+
additionalProperties: false,
62+
},
63+
},
64+
additionalProperties: false,
65+
},
66+
];
67+
68+
// The default options for the rule.
69+
const defaultOptions: Options = {
70+
mustBeReadonly: {
71+
pattern: "^Readonly",
72+
requireOthersToBeMutable: false,
73+
},
74+
mustBeMutable: {
75+
pattern: "^Mutable",
76+
requireOthersToBeReadonly: true,
77+
},
78+
};
79+
80+
// The possible error messages.
81+
const errorMessages = {
82+
mutable: "Mutable types should not be fully readonly.",
83+
readonly: "Readonly types should not be mutable at all.",
84+
mutableReadonly:
85+
"Configuration error - this type must be marked as both readonly and mutable.",
86+
needExplicitMarking:
87+
"Type must be explicity marked as either readonly or mutable.",
88+
} as const;
89+
90+
// The meta data for this rule.
91+
const meta: RuleMetaData<keyof typeof errorMessages> = {
92+
type: "suggestion",
93+
docs: {
94+
description: "Prefer readonly type alias over mutable one.",
95+
category: "Best Practices",
96+
recommended: "error",
97+
},
98+
messages: errorMessages,
99+
fixable: "code",
100+
schema,
101+
};
102+
103+
/**
104+
* Check if the given TypeReference violates this rule.
105+
*/
106+
function checkTypeAliasDeclaration(
107+
node: TSESTree.TSTypeAliasDeclaration,
108+
context: RuleContext<keyof typeof errorMessages, Options>,
109+
options: Options
110+
): RuleResult<keyof typeof errorMessages, Options> {
111+
const mustBeReadonlyPatterns = (
112+
Array.isArray(options.mustBeReadonly.pattern)
113+
? options.mustBeReadonly.pattern
114+
: [options.mustBeReadonly.pattern]
115+
).map((pattern) => new RegExp(pattern, "u"));
116+
117+
const mustBeMutablePatterns = (
118+
Array.isArray(options.mustBeMutable.pattern)
119+
? options.mustBeMutable.pattern
120+
: [options.mustBeMutable.pattern]
121+
).map((pattern) => new RegExp(pattern, "u"));
122+
123+
const patternStatesReadonly = mustBeReadonlyPatterns.some((pattern) =>
124+
pattern.test(node.id.name)
125+
);
126+
const patternStatesMutable = mustBeMutablePatterns.some((pattern) =>
127+
pattern.test(node.id.name)
128+
);
129+
130+
if (patternStatesReadonly && patternStatesMutable) {
131+
return {
132+
context,
133+
descriptors: [
134+
{
135+
node: node.id,
136+
messageId: "mutableReadonly",
137+
},
138+
],
139+
};
140+
}
141+
142+
if (
143+
!patternStatesReadonly &&
144+
!patternStatesMutable &&
145+
options.mustBeReadonly.requireOthersToBeMutable &&
146+
options.mustBeMutable.requireOthersToBeReadonly
147+
) {
148+
return {
149+
context,
150+
descriptors: [
151+
{
152+
node: node.id,
153+
messageId: "needExplicitMarking",
154+
},
155+
],
156+
};
157+
}
158+
159+
const requiredReadonlyness =
160+
patternStatesReadonly ||
161+
(!patternStatesMutable && options.mustBeMutable.requireOthersToBeReadonly)
162+
? RequiredReadonlyness.READONLY
163+
: patternStatesMutable ||
164+
(!patternStatesReadonly &&
165+
options.mustBeReadonly.requireOthersToBeMutable)
166+
? RequiredReadonlyness.MUTABLE
167+
: RequiredReadonlyness.EITHER;
168+
169+
return checkRequiredReadonlyness(
170+
node,
171+
context,
172+
options,
173+
requiredReadonlyness
174+
);
175+
}
176+
177+
function checkRequiredReadonlyness(
178+
node: TSESTree.TSTypeAliasDeclaration,
179+
context: RuleContext<keyof typeof errorMessages, Options>,
180+
options: Options,
181+
requiredReadonlyness: RequiredReadonlyness
182+
): RuleResult<keyof typeof errorMessages, Options> {
183+
if (requiredReadonlyness !== RequiredReadonlyness.EITHER) {
184+
const readonly = isReadonly(node.typeAnnotation, context);
185+
186+
if (readonly && requiredReadonlyness === RequiredReadonlyness.MUTABLE) {
187+
return {
188+
context,
189+
descriptors: [
190+
{
191+
node: node.id,
192+
messageId: "readonly",
193+
},
194+
],
195+
};
196+
}
197+
198+
if (!readonly && requiredReadonlyness === RequiredReadonlyness.READONLY) {
199+
return {
200+
context,
201+
descriptors: [
202+
{
203+
node: node.id,
204+
messageId: "mutable",
205+
},
206+
],
207+
};
208+
}
209+
}
210+
211+
return {
212+
context,
213+
descriptors: [],
214+
};
215+
}
216+
217+
// Create the rule.
218+
export const rule = createRule<keyof typeof errorMessages, Options>(
219+
name,
220+
meta,
221+
defaultOptions,
222+
{
223+
TSTypeAliasDeclaration: checkTypeAliasDeclaration,
224+
}
225+
);

src/util/rule.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,21 @@ export function getTypeOfNode<Context extends RuleContext<string, BaseOptions>>(
138138
return constrained ?? nodeType;
139139
}
140140

141+
export function isReadonly<Context extends RuleContext<string, BaseOptions>>(
142+
node: TSESTree.Node,
143+
context: Context
144+
): boolean {
145+
const { parserServices } = context;
146+
147+
if (parserServices === undefined || parserServices.program === undefined) {
148+
return false;
149+
}
150+
151+
const checker = parserServices.program.getTypeChecker();
152+
const type = getTypeOfNode(node, context);
153+
return ESLintUtils.isTypeReadonly(checker, type!);
154+
}
155+
141156
/**
142157
* Get the es tree node from the given ts node.
143158
*/
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { name, rule } from "~/rules/prefer-readonly-type-alias";
2+
import { testUsing } from "~/tests/helpers/testers";
3+
4+
import tsTests from "./ts";
5+
6+
testUsing.typescript(name, rule, tsTests);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import invalid from "./invalid";
2+
import valid from "./valid";
3+
4+
export default {
5+
valid,
6+
invalid,
7+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import dedent from "dedent";
2+
3+
import type { InvalidTestCase } from "~/tests/helpers/util";
4+
5+
const tests: ReadonlyArray<InvalidTestCase> = [
6+
// Readonly types should not be mutable.
7+
{
8+
code: dedent`
9+
type MyType = {
10+
a: string;
11+
};`,
12+
optionsSet: [[]],
13+
// output: dedent`
14+
// type MyType = {
15+
// readonly a: string;
16+
// };`,
17+
errors: [
18+
{
19+
messageId: "mutable",
20+
type: "Identifier",
21+
line: 1,
22+
column: 6,
23+
},
24+
],
25+
},
26+
// Mutable types should not be readonly.
27+
{
28+
code: dedent`
29+
type MutableMyType = {
30+
readonly a: string;
31+
};`,
32+
optionsSet: [[]],
33+
// output: dedent`
34+
// type MutableMyType = {
35+
// a: string;
36+
// };`,
37+
errors: [
38+
{
39+
messageId: "readonly",
40+
type: "Identifier",
41+
line: 1,
42+
column: 6,
43+
},
44+
],
45+
},
46+
];
47+
48+
export default tests;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import dedent from "dedent";
2+
3+
import type { ValidTestCase } from "~/tests/helpers/util";
4+
5+
const tests: ReadonlyArray<ValidTestCase> = [
6+
// Readonly types should be readonly.
7+
{
8+
code: dedent`
9+
type MyType = {
10+
readonly a: string;
11+
};`,
12+
optionsSet: [[]],
13+
},
14+
{
15+
code: dedent`
16+
type MutableMyType = {
17+
a: string;
18+
};
19+
type MyType = Readonly<MutableMyType>;`,
20+
optionsSet: [[]],
21+
},
22+
];
23+
24+
export default tests;

0 commit comments

Comments
 (0)