Skip to content

Commit f5ec4a3

Browse files
feat(prefer-readonly-type-alias): initial creation of rule
1 parent 0e2c490 commit f5ec4a3

File tree

9 files changed

+479
-0
lines changed

9 files changed

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

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+
};

0 commit comments

Comments
 (0)